package sweep import ( "crypto/rand" "errors" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) var ( errDummy = errors.New("dummy error") testPubKey, _ = btcec.ParsePubKey([]byte{ 0x04, 0x11, 0xdb, 0x93, 0xe1, 0xdc, 0xdb, 0x8a, 0x01, 0x6b, 0x49, 0x84, 0x0f, 0x8c, 0x53, 0xbc, 0x1e, 0xb6, 0x8a, 0x38, 0x2e, 0x97, 0xb1, 0x48, 0x2e, 0xca, 0xd7, 0xb1, 0x48, 0xa6, 0x90, 0x9a, 0x5c, 0xb2, 0xe0, 0xea, 0xdd, 0xfb, 0x84, 0xcc, 0xf9, 0x74, 0x44, 0x64, 0xf8, 0x2e, 0x16, 0x0b, 0xfa, 0x9b, 0x8b, 0x64, 0xf9, 0xd4, 0xc0, 0x3f, 0x99, 0x9b, 0x86, 0x43, 0xf6, 0x56, 0xb4, 0x12, 0xa3, }) ) // createMockInput creates a mock input and saves it to the sweeper's inputs // map. The created input has the specified state and a random outpoint. It // will assert the method `OutPoint` is called at least once. func createMockInput(t *testing.T, s *UtxoSweeper, state SweepState) *input.MockInput { inp := &input.MockInput{} t.Cleanup(func() { inp.AssertExpectations(t) }) randBuf := make([]byte, lntypes.HashSize) _, err := rand.Read(randBuf) require.NoError(t, err, "internal error, cannot generate random bytes") randHash, err := chainhash.NewHash(randBuf) require.NoError(t, err) inp.On("OutPoint").Return(wire.OutPoint{ Hash: *randHash, Index: 0, }) // We don't do branch switches based on the witness type here so we // just mock it. inp.On("WitnessType").Return(input.CommitmentTimeLock).Maybe() s.inputs[inp.OutPoint()] = &SweeperInput{ Input: inp, state: state, } return inp } // TestMarkInputsPendingPublish checks that given a list of inputs with // different states, only the non-terminal state will be marked as `Published`. func TestMarkInputsPendingPublish(t *testing.T) { t.Parallel() require := require.New(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{}) // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create three inputs with different states. // - inputInit specifies a newly created input. // - inputPendingPublish specifies an input about to be published. // - inputTerminated specifies an input that's terminated. var ( inputInit = createMockInput(t, s, Init) inputPendingPublish = createMockInput(t, s, PendingPublish) inputTerminated = createMockInput(t, s, Excluded) ) // Mark the test inputs. We expect the non-exist input and the // inputTerminated to be skipped, and the rest to be marked as pending // publish. set.On("Inputs").Return([]input.Input{ inputInit, inputPendingPublish, inputTerminated, }) s.markInputsPendingPublish(set) // We expect unchanged number of pending inputs. require.Len(s.inputs, 3) // We expect the init input's state to become pending publish. require.Equal(PendingPublish, s.inputs[inputInit.OutPoint()].state) // We expect the pending-publish to stay unchanged. require.Equal(PendingPublish, s.inputs[inputPendingPublish.OutPoint()].state) // We expect the terminated to stay unchanged. require.Equal(Excluded, s.inputs[inputTerminated.OutPoint()].state) } // TestMarkInputsPublished checks that given a list of inputs with different // states, only the state `PendingPublish` will be marked as `Published`. func TestMarkInputsPublished(t *testing.T) { t.Parallel() require := require.New(t) // Create a mock sweeper store. mockStore := NewMockSweeperStore() // Create a test TxRecord and a dummy error. dummyTR := &TxRecord{} dummyErr := errors.New("dummy error") // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Store: mockStore, }) // Create two inputs with different states. // - inputInit specifies a newly created input. // - inputPendingPublish specifies an input about to be published. var ( inputInit = createMockInput(t, s, Init) inputPendingPublish = createMockInput(t, s, PendingPublish) ) // First, check that when an error is returned from db, it's properly // returned here. mockStore.On("StoreTx", dummyTR).Return(dummyErr).Once() err := s.markInputsPublished(dummyTR, nil) require.ErrorIs(err, dummyErr) // We also expect the record has been marked as published. require.True(dummyTR.Published) // Then, check that the target input has will be correctly marked as // published. // // Mock the store to return nil mockStore.On("StoreTx", dummyTR).Return(nil).Once() // Mark the test inputs. We expect the non-exist input and the // inputInit to be skipped, and the final input to be marked as // published. set.On("Inputs").Return([]input.Input{inputInit, inputPendingPublish}) err = s.markInputsPublished(dummyTR, set) require.NoError(err) // We expect unchanged number of pending inputs. require.Len(s.inputs, 2) // We expect the init input's state to stay unchanged. require.Equal(Init, s.inputs[inputInit.OutPoint()].state) // We expect the pending-publish input's is now marked as published. require.Equal(Published, s.inputs[inputPendingPublish.OutPoint()].state) // Assert mocked statements are executed as expected. mockStore.AssertExpectations(t) } // TestMarkInputsPublishFailed checks that given a list of inputs with // different states, only the state `PendingPublish` and `Published` will be // marked as `PublishFailed`. func TestMarkInputsPublishFailed(t *testing.T) { t.Parallel() require := require.New(t) // Create a mock sweeper store. mockStore := NewMockSweeperStore() // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Store: mockStore, }) // Create inputs with different states. // - inputInit specifies a newly created input. When marking this as // published, we should see an error log as this input hasn't been // published yet. // - inputPendingPublish specifies an input about to be published. // - inputPublished specifies an input that's published. // - inputPublishFailed specifies an input that's failed to be // published. // - inputSwept specifies an input that's swept. // - inputExcluded specifies an input that's excluded. // - inputFatal specifies an input that's fatal. var ( inputInit = createMockInput(t, s, Init) inputPendingPublish = createMockInput(t, s, PendingPublish) inputPublished = createMockInput(t, s, Published) inputPublishFailed = createMockInput(t, s, PublishFailed) inputSwept = createMockInput(t, s, Swept) inputExcluded = createMockInput(t, s, Excluded) inputFatal = createMockInput(t, s, Fatal) ) // Gather all inputs. set.On("Inputs").Return([]input.Input{ inputInit, inputPendingPublish, inputPublished, inputPublishFailed, inputSwept, inputExcluded, inputFatal, }) // Mark the test inputs. We expect the non-exist input and the // inputInit to be skipped, and the final input to be marked as // published. s.markInputsPublishFailed(set) // We expect unchanged number of pending inputs. require.Len(s.inputs, 7) // We expect the init input's state to stay unchanged. require.Equal(Init, s.inputs[inputInit.OutPoint()].state) // We expect the pending-publish input's is now marked as publish // failed. require.Equal(PublishFailed, s.inputs[inputPendingPublish.OutPoint()].state) // We expect the published input's is now marked as publish failed. require.Equal(PublishFailed, s.inputs[inputPublished.OutPoint()].state) // We expect the publish failed input to stay unchanged. require.Equal(PublishFailed, s.inputs[inputPublishFailed.OutPoint()].state) // We expect the swept input to stay unchanged. require.Equal(Swept, s.inputs[inputSwept.OutPoint()].state) // We expect the excluded input to stay unchanged. require.Equal(Excluded, s.inputs[inputExcluded.OutPoint()].state) // We expect the failed input to stay unchanged. require.Equal(Fatal, s.inputs[inputFatal.OutPoint()].state) // Assert mocked statements are executed as expected. mockStore.AssertExpectations(t) } // TestMarkInputsSwept checks that given a list of inputs with different // states, only the non-terminal state will be marked as `Swept`. func TestMarkInputsSwept(t *testing.T) { t.Parallel() require := require.New(t) // Create a mock input. mockInput := &input.MockInput{} defer mockInput.AssertExpectations(t) // Mock the `OutPoint` to return a dummy outpoint. mockInput.On("OutPoint").Return(wire.OutPoint{Hash: chainhash.Hash{1}}) // Create a test sweeper. s := New(&UtxoSweeperConfig{}) // Create three testing inputs. // // inputNotExist specifies an input that's not found in the sweeper's // `inputs` map. inputNotExist := &wire.TxIn{ PreviousOutPoint: wire.OutPoint{Index: 1}, } // inputInit specifies a newly created input. inputInit := &wire.TxIn{ PreviousOutPoint: wire.OutPoint{Index: 2}, } s.inputs[inputInit.PreviousOutPoint] = &SweeperInput{ state: Init, Input: mockInput, } // inputPendingPublish specifies an input that's about to be published. inputPendingPublish := &wire.TxIn{ PreviousOutPoint: wire.OutPoint{Index: 3}, } s.inputs[inputPendingPublish.PreviousOutPoint] = &SweeperInput{ state: PendingPublish, Input: mockInput, } // inputTerminated specifies an input that's terminated. inputTerminated := &wire.TxIn{ PreviousOutPoint: wire.OutPoint{Index: 4}, } s.inputs[inputTerminated.PreviousOutPoint] = &SweeperInput{ state: Excluded, Input: mockInput, } tx := &wire.MsgTx{ TxIn: []*wire.TxIn{ inputNotExist, inputInit, inputPendingPublish, inputTerminated, }, } // Mark the test inputs. We expect the inputTerminated to be skipped, // and the rest to be marked as swept. s.markInputsSwept(tx, true) // We expect unchanged number of pending inputs. require.Len(s.inputs, 3) // We expect the init input's state to become swept. require.Equal(Swept, s.inputs[inputInit.PreviousOutPoint].state) // We expect the pending-publish becomes swept. require.Equal(Swept, s.inputs[inputPendingPublish.PreviousOutPoint].state) // We expect the terminated to stay unchanged. require.Equal(Excluded, s.inputs[inputTerminated.PreviousOutPoint].state) } // TestMempoolLookup checks that the method `mempoolLookup` works as expected. func TestMempoolLookup(t *testing.T) { t.Parallel() require := require.New(t) // Create a test outpoint. op := wire.OutPoint{Index: 1} // Create a mock mempool watcher. mockMempool := chainntnfs.NewMockMempoolWatcher() defer mockMempool.AssertExpectations(t) // Create a test sweeper without a mempool. s := New(&UtxoSweeperConfig{}) // Since we don't have a mempool, we expect the call to return a // fn.None indicating it's not found. tx := s.mempoolLookup(op) require.True(tx.IsNone()) // Re-create the sweeper with the mocked mempool watcher. s = New(&UtxoSweeperConfig{ Mempool: mockMempool, }) // Mock the mempool watcher to return not found. mockMempool.On("LookupInputMempoolSpend", op).Return( fn.None[wire.MsgTx]()).Once() // We expect a fn.None tx to be returned. tx = s.mempoolLookup(op) require.True(tx.IsNone()) // Mock the mempool to return a spending tx. dummyTx := wire.MsgTx{} mockMempool.On("LookupInputMempoolSpend", op).Return( fn.Some(dummyTx)).Once() // Calling the loopup again, we expect the dummyTx to be returned. tx = s.mempoolLookup(op) require.False(tx.IsNone()) require.Equal(dummyTx, tx.UnsafeFromSome()) } // TestUpdateSweeperInputs checks that the method `updateSweeperInputs` will // properly update the inputs based on their states. func TestUpdateSweeperInputs(t *testing.T) { t.Parallel() require := require.New(t) // Create a test sweeper. s := New(nil) // Create mock inputs. inp1 := &input.MockInput{} defer inp1.AssertExpectations(t) inp2 := &input.MockInput{} defer inp2.AssertExpectations(t) inp3 := &input.MockInput{} defer inp3.AssertExpectations(t) // Create a list of inputs using all the states. // // Mock the input to have a locktime that's matured so it will be // returned. inp1.On("RequiredLockTime").Return( uint32(s.currentHeight), false).Once() inp1.On("BlocksToMaturity").Return(uint32(0)).Once() inp1.On("HeightHint").Return(uint32(s.currentHeight)).Once() input0 := &SweeperInput{state: Init, Input: inp1} // These inputs won't hit RequiredLockTime so we won't mock. input1 := &SweeperInput{state: PendingPublish, Input: inp1} input2 := &SweeperInput{state: Published, Input: inp1} // Mock the input to have a locktime that's matured so it will be // returned. inp1.On("RequiredLockTime").Return( uint32(s.currentHeight), false).Once() inp1.On("BlocksToMaturity").Return(uint32(0)).Once() inp1.On("HeightHint").Return(uint32(s.currentHeight)).Once() input3 := &SweeperInput{state: PublishFailed, Input: inp1} // These inputs won't hit RequiredLockTime so we won't mock. input4 := &SweeperInput{state: Swept, Input: inp1} input5 := &SweeperInput{state: Excluded, Input: inp1} input6 := &SweeperInput{state: Fatal, Input: inp1} // Mock the input to have a locktime in the future so it will NOT be // returned. inp2.On("RequiredLockTime").Return( uint32(s.currentHeight+1), true).Once() inp2.On("OutPoint").Return(wire.OutPoint{Index: 2}).Maybe() input7 := &SweeperInput{state: Init, Input: inp2} // Mock the input to have a CSV expiry in the future so it will NOT be // returned. inp3.On("RequiredLockTime").Return( uint32(s.currentHeight), false).Once() inp3.On("BlocksToMaturity").Return(uint32(2)).Once() inp3.On("HeightHint").Return(uint32(s.currentHeight)).Once() inp3.On("OutPoint").Return(wire.OutPoint{Index: 3}).Maybe() input8 := &SweeperInput{state: Init, Input: inp3} // Add the inputs to the sweeper. After the update, we should see the // terminated inputs being removed. s.inputs = map[wire.OutPoint]*SweeperInput{ {Index: 0}: input0, {Index: 1}: input1, {Index: 2}: input2, {Index: 3}: input3, {Index: 4}: input4, {Index: 5}: input5, {Index: 6}: input6, {Index: 7}: input7, {Index: 8}: input8, } // We expect the inputs with `Swept`, `Excluded`, and `Failed` to be // removed. expectedInputs := map[wire.OutPoint]*SweeperInput{ {Index: 0}: input0, {Index: 1}: input1, {Index: 2}: input2, {Index: 3}: input3, {Index: 7}: input7, {Index: 8}: input8, } // We expect only the inputs with `Init` and `PublishFailed` to be // returned. expectedReturn := map[wire.OutPoint]*SweeperInput{ {Index: 0}: input0, {Index: 3}: input3, } // Update the sweeper inputs. inputs := s.updateSweeperInputs() // Assert the returned inputs are as expected. require.Equal(expectedReturn, inputs) // Assert the sweeper inputs are as expected. require.Equal(expectedInputs, s.inputs) } // TestDecideRBFInfo checks that the expected RBFInfo is returned based on // whether this input can be found both in mempool and the sweeper store. func TestDecideRBFInfo(t *testing.T) { t.Parallel() require := require.New(t) // Create a test outpoint. op := wire.OutPoint{Index: 1} // Create a mock mempool watcher and a mock sweeper store. mockMempool := chainntnfs.NewMockMempoolWatcher() defer mockMempool.AssertExpectations(t) mockStore := NewMockSweeperStore() defer mockStore.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Store: mockStore, Mempool: mockMempool, }) // First, mock the mempool to return false. mockMempool.On("LookupInputMempoolSpend", op).Return( fn.None[wire.MsgTx]()).Once() // Since the mempool lookup failed, we expect no RBFInfo. rbf := s.decideRBFInfo(op) require.True(rbf.IsNone()) // Mock the mempool lookup to return a tx three times as we are calling // attachAvailableRBFInfo three times. tx := wire.MsgTx{} mockMempool.On("LookupInputMempoolSpend", op).Return( fn.Some(tx)).Times(3) // Mock the store to return an error saying the tx cannot be found. mockStore.On("GetTx", tx.TxHash()).Return(nil, ErrTxNotFound).Once() // The db lookup failed, we expect no RBFInfo. rbf = s.decideRBFInfo(op) require.True(rbf.IsNone()) // Mock the store to return a db error. dummyErr := errors.New("dummy error") mockStore.On("GetTx", tx.TxHash()).Return(nil, dummyErr).Once() // The db lookup failed, we expect no RBFInfo. rbf = s.decideRBFInfo(op) require.True(rbf.IsNone()) // Mock the store to return a record. tr := &TxRecord{ Fee: 100, FeeRate: 100, } mockStore.On("GetTx", tx.TxHash()).Return(tr, nil).Once() // Call the method again. rbf = s.decideRBFInfo(op) // Assert that the RBF info is returned. rbfInfo := fn.Some(RBFInfo{ Txid: tx.TxHash(), Fee: btcutil.Amount(tr.Fee), FeeRate: chainfee.SatPerKWeight(tr.FeeRate), }) require.Equal(rbfInfo, rbf) } // TestMarkInputFatal checks that the input is marked as expected. func TestMarkInputFailed(t *testing.T) { t.Parallel() // Create a mock input. mockInput := &input.MockInput{} defer mockInput.AssertExpectations(t) // Mock the `OutPoint` to return a dummy outpoint. mockInput.On("OutPoint").Return(wire.OutPoint{Hash: chainhash.Hash{1}}) // Create a test sweeper. s := New(&UtxoSweeperConfig{}) // Create a testing pending input. pi := &SweeperInput{ state: Init, Input: mockInput, } // Call the method under test. s.markInputFatal(pi, nil, errors.New("dummy error")) // Assert the state is updated. require.Equal(t, Fatal, pi.state) } // TestSweepPendingInputs checks that `sweepPendingInputs` correctly executes // its workflow based on the returned values from the interfaces. func TestSweepPendingInputs(t *testing.T) { t.Parallel() // Create a mock wallet and aggregator. wallet := &MockWallet{} defer wallet.AssertExpectations(t) aggregator := &mockUtxoAggregator{} defer aggregator.AssertExpectations(t) publisher := &MockBumper{} defer publisher.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Wallet: wallet, Aggregator: aggregator, Publisher: publisher, GenSweepScript: func() fn.Result[lnwallet.AddrWithKey] { //nolint:ll return fn.Ok(lnwallet.AddrWithKey{ DeliveryAddress: testPubKey.SerializeCompressed(), }) }, NoDeadlineConfTarget: uint32(DefaultDeadlineDelta), }) // Set a current height to test the deadline override. s.currentHeight = testHeight // Create an input set that needs wallet inputs. setNeedWallet := &MockInputSet{} defer setNeedWallet.AssertExpectations(t) // Mock this set to ask for wallet input. setNeedWallet.On("NeedWalletInput").Return(true).Once() setNeedWallet.On("AddWalletInputs", wallet).Return(nil).Once() // Mock the wallet to require the lock once. wallet.On("WithCoinSelectLock", mock.Anything).Return(nil).Once() // Create an input set that doesn't need wallet inputs. normalSet := &MockInputSet{} defer normalSet.AssertExpectations(t) normalSet.On("NeedWalletInput").Return(false).Once() // Mock the methods used in `sweep`. This is not important for this // unit test. setNeedWallet.On("Inputs").Return(nil).Maybe() setNeedWallet.On("DeadlineHeight").Return(testHeight).Once() setNeedWallet.On("Budget").Return(btcutil.Amount(1)).Once() setNeedWallet.On("StartingFeeRate").Return( fn.None[chainfee.SatPerKWeight]()).Once() setNeedWallet.On("Immediate").Return(false).Once() normalSet.On("Inputs").Return(nil).Maybe() normalSet.On("DeadlineHeight").Return(testHeight).Once() normalSet.On("Budget").Return(btcutil.Amount(1)).Once() normalSet.On("StartingFeeRate").Return( fn.None[chainfee.SatPerKWeight]()).Once() normalSet.On("Immediate").Return(false).Once() // Make pending inputs for testing. We don't need real values here as // the returned clusters are mocked. pis := make(InputsMap) // Mock the aggregator to return the mocked input sets. aggregator.On("ClusterInputs", pis).Return([]InputSet{ setNeedWallet, normalSet, }) // Mock `Broadcast` to return a result. publisher.On("Broadcast", mock.Anything).Return(nil).Twice() // Call the method under test. s.sweepPendingInputs(pis) } // TestHandleBumpEventTxFailed checks that the sweeper correctly handles the // case where the bump event tx fails to be published. func TestHandleBumpEventTxFailed(t *testing.T) { t.Parallel() // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{}) // inputNotExist specifies an input that's not found in the sweeper's // `pendingInputs` map. inputNotExist := &input.MockInput{} defer inputNotExist.AssertExpectations(t) inputNotExist.On("OutPoint").Return(wire.OutPoint{Index: 0}) opNotExist := inputNotExist.OutPoint() // Create three mock inputs. var ( input1 = createMockInput(t, s, PendingPublish) input2 = createMockInput(t, s, PendingPublish) input3 = createMockInput(t, s, PendingPublish) ) op1 := input1.OutPoint() op2 := input2.OutPoint() op3 := input3.OutPoint() // Construct the initial state for the sweeper. set.On("Inputs").Return([]input.Input{input1, input2, input3}) // Create a testing tx that spends the first two inputs. tx := &wire.MsgTx{ TxIn: []*wire.TxIn{ {PreviousOutPoint: op1}, {PreviousOutPoint: op2}, {PreviousOutPoint: opNotExist}, }, } // Create a testing bump result. br := &BumpResult{ Tx: tx, Event: TxFailed, Err: errDummy, } // Create a testing bump response. resp := &bumpResp{ result: br, set: set, } // Call the method under test. err := s.handleBumpEvent(resp) require.NoError(t, err) // Assert the states of the first two inputs are updated. require.Equal(t, PublishFailed, s.inputs[op1].state) require.Equal(t, PublishFailed, s.inputs[op2].state) // Assert the state of the third input. // // NOTE: Although the tx doesn't spend it, we still mark this input as // failed as we are treating the input set as the single source of // truth. require.Equal(t, PublishFailed, s.inputs[op3].state) // Assert the non-existing input is not added to the pending inputs. require.NotContains(t, s.inputs, opNotExist) } // TestHandleBumpEventTxReplaced checks that the sweeper correctly handles the // case where the bump event tx is replaced. func TestHandleBumpEventTxReplaced(t *testing.T) { t.Parallel() // Create a mock store. store := &MockSweeperStore{} defer store.AssertExpectations(t) // Create a mock wallet. wallet := &MockWallet{} defer wallet.AssertExpectations(t) // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Store: store, Wallet: wallet, }) // Create a mock input. inp := createMockInput(t, s, PendingPublish) set.On("Inputs").Return([]input.Input{inp}) op := inp.OutPoint() // Create a testing tx that spends the input. tx := &wire.MsgTx{ LockTime: 1, TxIn: []*wire.TxIn{ {PreviousOutPoint: op}, }, } // Create a replacement tx. replacementTx := &wire.MsgTx{ LockTime: 2, TxIn: []*wire.TxIn{ {PreviousOutPoint: op}, }, } // Create a testing bump result. br := &BumpResult{ Tx: replacementTx, ReplacedTx: tx, Event: TxReplaced, } // Create a testing bump response. resp := &bumpResp{ result: br, set: set, } // Mock the store to return an error. dummyErr := errors.New("dummy error") store.On("GetTx", tx.TxHash()).Return(nil, dummyErr).Once() // Call the method under test and assert the error is returned. err := s.handleBumpEventTxReplaced(resp) require.ErrorIs(t, err, dummyErr) // Mock the store to return the old tx record. store.On("GetTx", tx.TxHash()).Return(&TxRecord{ Txid: tx.TxHash(), }, nil).Once() // We expect to cancel rebroadcasting the replaced tx. wallet.On("CancelRebroadcast", tx.TxHash()).Once() // Mock an error returned when deleting the old tx record. store.On("DeleteTx", tx.TxHash()).Return(dummyErr).Once() // Call the method under test and assert the error is returned. err = s.handleBumpEventTxReplaced(resp) require.ErrorIs(t, err, dummyErr) // Mock the store to return the old tx record and delete it without // error. store.On("GetTx", tx.TxHash()).Return(&TxRecord{ Txid: tx.TxHash(), }, nil).Once() store.On("DeleteTx", tx.TxHash()).Return(nil).Once() // Mock the store to save the new tx record. store.On("StoreTx", &TxRecord{ Txid: replacementTx.TxHash(), Published: true, }).Return(nil).Once() // We expect to cancel rebroadcasting the replaced tx. wallet.On("CancelRebroadcast", tx.TxHash()).Once() // Call the method under test. err = s.handleBumpEventTxReplaced(resp) require.NoError(t, err) // Assert the state of the input is updated. require.Equal(t, Published, s.inputs[op].state) } // TestHandleBumpEventTxPublished checks that the sweeper correctly handles the // case where the bump event tx is published. func TestHandleBumpEventTxPublished(t *testing.T) { t.Parallel() // Create a mock store. store := &MockSweeperStore{} defer store.AssertExpectations(t) // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Store: store, }) // Create a mock input. inp := createMockInput(t, s, PendingPublish) set.On("Inputs").Return([]input.Input{inp}) op := inp.OutPoint() // Create a testing tx that spends the input. tx := &wire.MsgTx{ LockTime: 1, TxIn: []*wire.TxIn{ {PreviousOutPoint: op}, }, } // Create a testing bump result. br := &BumpResult{ Tx: tx, Event: TxPublished, } // Create a testing bump response. resp := &bumpResp{ result: br, set: set, } // Mock the store to save the new tx record. store.On("StoreTx", &TxRecord{ Txid: tx.TxHash(), Published: true, }).Return(nil).Once() // Call the method under test. err := s.handleBumpEventTxPublished(resp) require.NoError(t, err) // Assert the state of the input is updated. require.Equal(t, Published, s.inputs[op].state) } // TestMonitorFeeBumpResult checks that the fee bump monitor loop correctly // exits when the sweeper is stopped, the tx is confirmed or failed. func TestMonitorFeeBumpResult(t *testing.T) { // Create a mock store. store := &MockSweeperStore{} defer store.AssertExpectations(t) // Create a mock wallet. wallet := &MockWallet{} defer wallet.AssertExpectations(t) // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Store: store, Wallet: wallet, }) // Create a mock input. inp := createMockInput(t, s, PendingPublish) // Create a testing tx that spends the input. op := inp.OutPoint() tx := &wire.MsgTx{ LockTime: 1, TxIn: []*wire.TxIn{ {PreviousOutPoint: op}, }, } testCases := []struct { name string setupResultChan func() <-chan *BumpResult shouldExit bool }{ { // When a tx confirmed event is received, we expect to // exit the monitor loop. name: "tx confirmed", // We send a result with TxConfirmed event to the // result channel. setupResultChan: func() <-chan *BumpResult { // Create a result chan. resultChan := make(chan *BumpResult, 1) resultChan <- &BumpResult{ Tx: tx, Event: TxConfirmed, Fee: 10000, FeeRate: 100, } // We expect to cancel rebroadcasting the tx // once confirmed. wallet.On("CancelRebroadcast", tx.TxHash()).Once() return resultChan }, shouldExit: true, }, { // When a tx failed event is received, we expect to // exit the monitor loop. name: "tx failed", // We send a result with TxConfirmed event to the // result channel. setupResultChan: func() <-chan *BumpResult { // Create a result chan. resultChan := make(chan *BumpResult, 1) resultChan <- &BumpResult{ Tx: tx, Event: TxFailed, Err: errDummy, } // We expect to cancel rebroadcasting the tx // once failed. wallet.On("CancelRebroadcast", tx.TxHash()).Once() return resultChan }, shouldExit: true, }, { // When processing non-confirmed events, the monitor // should not exit. name: "no exit on normal event", // We send a result with TxPublished and mock the // method `StoreTx` to return nil. setupResultChan: func() <-chan *BumpResult { // Create a result chan. resultChan := make(chan *BumpResult, 1) resultChan <- &BumpResult{ Tx: tx, Event: TxPublished, } return resultChan }, shouldExit: false, }, { // When the sweeper is shutting down, the monitor loop // should exit. name: "exit on sweeper shutdown", // We don't send anything but quit the sweeper. setupResultChan: func() <-chan *BumpResult { close(s.quit) return nil }, shouldExit: true, }, } for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { // Setup the testing result channel. resultChan := tc.setupResultChan() // Create a done chan that's used to signal the monitor // has exited. done := make(chan struct{}) s.wg.Add(1) go func() { s.monitorFeeBumpResult(set, resultChan) close(done) }() // The monitor is expected to exit, we check it's done // in one second or fail. if tc.shouldExit { select { case <-done: case <-time.After(1 * time.Second): require.Fail(t, "monitor not exited") } return } // The monitor should not exit, check it doesn't close // the `done` channel within one second. select { case <-done: require.Fail(t, "monitor exited") case <-time.After(1 * time.Second): } }) } } // TestMarkInputsFailed checks that given a list of inputs with different // states, the method `markInputsFailed` correctly marks the inputs as failed. func TestMarkInputsFailed(t *testing.T) { t.Parallel() require := require.New(t) // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{}) // Create testing inputs for each state. // - inputInit specifies a newly created input. When marking this as // published, we should see an error log as this input hasn't been // published yet. // - inputPendingPublish specifies an input about to be published. // - inputPublished specifies an input that's published. // - inputPublishFailed specifies an input that's failed to be // published. // - inputSwept specifies an input that's swept. // - inputExcluded specifies an input that's excluded. // - inputFatal specifies an input that's fatal. var ( inputInit = createMockInput(t, s, Init) inputPendingPublish = createMockInput(t, s, PendingPublish) inputPublished = createMockInput(t, s, Published) inputPublishFailed = createMockInput(t, s, PublishFailed) inputSwept = createMockInput(t, s, Swept) inputExcluded = createMockInput(t, s, Excluded) inputFatal = createMockInput(t, s, Fatal) ) // Gather all inputs. set.On("Inputs").Return([]input.Input{ inputInit, inputPendingPublish, inputPublished, inputPublishFailed, inputSwept, inputExcluded, inputFatal, }) // Mark the test inputs. We expect the non-exist input and // inputSwept/inputExcluded/inputFatal to be skipped. s.markInputsFatal(set, errDummy) // We expect unchanged number of pending inputs. require.Len(s.inputs, 7) // We expect the init input's to be marked as fatal. require.Equal(Fatal, s.inputs[inputInit.OutPoint()].state) // We expect the pending-publish input to be marked as failed. require.Equal(Fatal, s.inputs[inputPendingPublish.OutPoint()].state) // We expect the published input to be marked as fatal. require.Equal(Fatal, s.inputs[inputPublished.OutPoint()].state) // We expect the publish failed input to be markd as failed. require.Equal(Fatal, s.inputs[inputPublishFailed.OutPoint()].state) // We expect the swept input to stay unchanged. require.Equal(Swept, s.inputs[inputSwept.OutPoint()].state) // We expect the excluded input to stay unchanged. require.Equal(Excluded, s.inputs[inputExcluded.OutPoint()].state) // We expect the failed input to stay unchanged. require.Equal(Fatal, s.inputs[inputFatal.OutPoint()].state) } // TestHandleBumpEventTxFatal checks that `handleBumpEventTxFatal` correctly // handles a `TxFatal` event. func TestHandleBumpEventTxFatal(t *testing.T) { t.Parallel() rt := require.New(t) // Create a mock store. store := &MockSweeperStore{} defer store.AssertExpectations(t) // Create a mock input set. We are not testing `markInputFailed` here, // so the actual set doesn't matter. set := &MockInputSet{} defer set.AssertExpectations(t) set.On("Inputs").Return(nil) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Store: store, }) // Create a dummy tx. tx := &wire.MsgTx{ LockTime: 1, } // Create a testing bump response. result := &BumpResult{ Err: errDummy, Tx: tx, } resp := &bumpResp{ result: result, set: set, } // Mock the store to return an error. store.On("DeleteTx", mock.Anything).Return(errDummy).Once() // Call the method under test and assert the error is returned. err := s.handleBumpEventTxFatal(resp) rt.ErrorIs(err, errDummy) // Mock the store to return nil. store.On("DeleteTx", mock.Anything).Return(nil).Once() // Call the method under test and assert no error is returned. err = s.handleBumpEventTxFatal(resp) rt.NoError(err) } // TestHandleUnknownSpendTxOurs checks that `handleUnknownSpendTx` correctly // marks an input as swept given the tx is ours. func TestHandleUnknownSpendTxOurs(t *testing.T) { t.Parallel() // Create a mock store. store := &MockSweeperStore{} defer store.AssertExpectations(t) // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Store: store, }) // Create a mock input. inp := createMockInput(t, s, PublishFailed) op := inp.OutPoint() si, ok := s.inputs[op] require.True(t, ok) // Create a testing tx that spends the input. tx := &wire.MsgTx{ LockTime: 1, TxIn: []*wire.TxIn{ {PreviousOutPoint: op}, }, } txid := tx.TxHash() // Mock the store to return true when calling IsOurTx. store.On("IsOurTx", txid).Return(true).Once() // Call the method under test. s.handleUnknownSpendTx(si, tx) // Assert the state of the input is updated. require.Equal(t, Swept, s.inputs[op].state) } // TestHandleUnknownSpendTxThirdParty checks that `handleUnknownSpendTx` // correctly marks an input as fatal given the tx is not ours. func TestHandleInputSpendTxThirdParty(t *testing.T) { t.Parallel() // Create a mock store. store := &MockSweeperStore{} defer store.AssertExpectations(t) // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Store: store, }) // Create a mock input. inp := createMockInput(t, s, PublishFailed) op := inp.OutPoint() si, ok := s.inputs[op] require.True(t, ok) // Create a testing tx that spends the input. tx := &wire.MsgTx{ LockTime: 1, TxIn: []*wire.TxIn{ {PreviousOutPoint: op}, }, } txid := tx.TxHash() // Mock the store to return false when calling IsOurTx. store.On("IsOurTx", txid).Return(false).Once() // Mock `ListSweeps` to return an empty slice as we are testing the // workflow here, not the method `removeConflictSweepDescendants`. store.On("ListSweeps").Return([]chainhash.Hash{}, nil).Once() // Call the method under test. s.handleUnknownSpendTx(si, tx) // Assert the state of the input is updated. require.Equal(t, Fatal, s.inputs[op].state) } // TestHandleBumpEventTxUnknownSpendNoRetry checks the case when all the inputs // are failed due to them being spent by another party. func TestHandleBumpEventTxUnknownSpendNoRetry(t *testing.T) { t.Parallel() // Create a mock store. store := &MockSweeperStore{} defer store.AssertExpectations(t) // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Store: store, }) // Create a mock input. inp := createMockInput(t, s, PendingPublish) set.On("Inputs").Return([]input.Input{inp}) op := inp.OutPoint() // Create a testing tx that spends the input. tx := &wire.MsgTx{ LockTime: 1, TxIn: []*wire.TxIn{ {PreviousOutPoint: op}, }, } txid := tx.TxHash() // Create a testing bump result. br := &BumpResult{ Tx: tx, Event: TxUnknownSpend, SpentInputs: map[wire.OutPoint]*wire.MsgTx{ op: tx, }, } // Create a testing bump response. resp := &bumpResp{ result: br, set: set, } // Mock the store to return true when calling IsOurTx. store.On("IsOurTx", txid).Return(true).Once() // Call the method under test. s.handleBumpEventTxUnknownSpend(resp) // Assert the state of the input is updated. require.Equal(t, Swept, s.inputs[op].state) } // TestHandleBumpEventTxUnknownSpendWithRetry checks the case when some the // inputs are retried after the bad inputs are filtered out. func TestHandleBumpEventTxUnknownSpendWithRetry(t *testing.T) { t.Parallel() // Create a mock store. store := &MockSweeperStore{} defer store.AssertExpectations(t) // Create a mock wallet and aggregator. wallet := &MockWallet{} defer wallet.AssertExpectations(t) aggregator := &mockUtxoAggregator{} defer aggregator.AssertExpectations(t) publisher := &MockBumper{} defer publisher.AssertExpectations(t) // Create a test sweeper. s := New(&UtxoSweeperConfig{ Wallet: wallet, Aggregator: aggregator, Publisher: publisher, GenSweepScript: func() fn.Result[lnwallet.AddrWithKey] { //nolint:ll return fn.Ok(lnwallet.AddrWithKey{ DeliveryAddress: testPubKey.SerializeCompressed(), }) }, NoDeadlineConfTarget: uint32(DefaultDeadlineDelta), Store: store, }) // Create a mock input set. set := &MockInputSet{} defer set.AssertExpectations(t) // Create mock inputs - inp1 will be the bad input, and inp2 will be // retried. inp1 := createMockInput(t, s, PendingPublish) inp2 := createMockInput(t, s, PendingPublish) set.On("Inputs").Return([]input.Input{inp1, inp2}) op1 := inp1.OutPoint() op2 := inp2.OutPoint() inp2.On("RequiredLockTime").Return( uint32(s.currentHeight), false).Once() inp2.On("BlocksToMaturity").Return(uint32(0)).Once() inp2.On("HeightHint").Return(uint32(s.currentHeight)).Once() // Create a testing tx that spends inp1. tx := &wire.MsgTx{ LockTime: 1, TxIn: []*wire.TxIn{ {PreviousOutPoint: op1}, }, } txid := tx.TxHash() // Create a testing bump result. br := &BumpResult{ Tx: tx, Event: TxUnknownSpend, SpentInputs: map[wire.OutPoint]*wire.MsgTx{ op1: tx, }, } // Create a testing bump response. resp := &bumpResp{ result: br, set: set, } // Mock the store to return true when calling IsOurTx. store.On("IsOurTx", txid).Return(true).Once() // Mock the aggregator to return an empty slice as we are not testing // the actual sweeping behavior. aggregator.On("ClusterInputs", mock.Anything).Return([]InputSet{}) // Call the method under test. s.handleBumpEventTxUnknownSpend(resp) // Assert the first input is removed. require.NotContains(t, s.inputs, op1) // Assert the state of the input is updated. require.Equal(t, PublishFailed, s.inputs[op2].state) }