contractcourt: add new chainWatcherTestHarness
What changed, and why it matters
This commit only adds a new test helper file for the contractcourt package. It contains no production code changes, no bug fixes, and no security-sensitive logic. It is purely test infrastructure to make future chain-watcher tests easier to write.
No action required. This is a test-only addition with no security relevance.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit introduces contractcourt/chain_watcher_test_harness.go, a new Go test harness that wraps chainWatcher setup with mock chain notifiers and convenience methods for simulating spends, confirmations, and reorganizations in unit tests. The file is only compiled into test binaries (it imports testing and is in a _test.go-equivalent helper, though not named _test.go it is clearly test-only code). No runtime behavior of lnd is changed.
Changed components
contractcourt/chain_watcher_test_harness.goInspect captured patch +656 / −0
diff --git a/contractcourt/chain_watcher_test_harness.go b/contractcourt/chain_watcher_test_harness.go
new file mode 100644
index 0000000..09ab035
--- /dev/null
+++ b/contractcourt/chain_watcher_test_harness.go
@@ -0,0 +1,656 @@
+package contractcourt
+
+import (
+ "testing"
+ "time"
+
+ "github.com/btcsuite/btcd/chaincfg/chainhash"
+ "github.com/btcsuite/btcd/wire"
+ "github.com/lightningnetwork/lnd/chainntnfs"
+ "github.com/lightningnetwork/lnd/channeldb"
+ "github.com/lightningnetwork/lnd/fn/v2"
+ lnmock "github.com/lightningnetwork/lnd/lntest/mock"
+ "github.com/lightningnetwork/lnd/lnwallet"
+ "github.com/lightningnetwork/lnd/lnwire"
+)
+
+// testReporter is a minimal interface for test reporting that is satisfied
+// by both *testing.T and *rapid.T, allowing the harness to work with
+// property-based tests.
+type testReporter interface {
+ Helper()
+ Fatalf(format string, args ...any)
+}
+
+// chainWatcherTestHarness provides a test harness for chain watcher tests
+// with utilities for simulating spends, confirmations, and reorganizations.
+type chainWatcherTestHarness struct {
+ t testReporter
+
+ // aliceChannel and bobChannel are the test channels.
+ aliceChannel *lnwallet.LightningChannel
+ bobChannel *lnwallet.LightningChannel
+
+ // chainWatcher is the chain watcher under test.
+ chainWatcher *chainWatcher
+
+ // notifier is the mock chain notifier.
+ notifier *mockChainNotifier
+
+ // chanEvents is the channel event subscription.
+ chanEvents *ChainEventSubscription
+
+ // currentHeight tracks the current block height.
+ currentHeight int32
+
+ // blockbeatProcessed is a channel that signals when a blockbeat has
+ // been processed.
+ blockbeatProcessed chan struct{}
+}
+
+// mockChainNotifier extends the standard mock with additional channels for
+// testing cooperative close reorgs.
+type mockChainNotifier struct {
+ *lnmock.ChainNotifier
+
+ // confEvents tracks active confirmation event subscriptions.
+ confEvents []*mockConfirmationEvent
+
+ // confRegistered is a channel that signals when a new confirmation
+ // event has been registered.
+ confRegistered chan struct{}
+
+ // spendEvents tracks active spend event subscriptions.
+ spendEvents []*chainntnfs.SpendEvent
+
+ // spendRegistered is a channel that signals when a new spend
+ // event has been registered.
+ spendRegistered chan struct{}
+}
+
+// mockConfirmationEvent represents a mock confirmation event subscription.
+type mockConfirmationEvent struct {
+ txid chainhash.Hash
+ numConfs uint32
+ confirmedChan chan *chainntnfs.TxConfirmation
+ negConfChan chan int32
+ cancelled bool
+}
+
+// RegisterSpendNtfn creates a new mock spend event.
+func (m *mockChainNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint,
+ pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) {
+
+ // The base mock already has SpendChan, use that.
+ spendEvent := &chainntnfs.SpendEvent{
+ Spend: m.SpendChan,
+ Cancel: func() {
+ // No-op for now.
+ },
+ }
+
+ m.spendEvents = append(m.spendEvents, spendEvent)
+
+ // Signal that a new spend event has been registered.
+ select {
+ case m.spendRegistered <- struct{}{}:
+ default:
+ }
+
+ return spendEvent, nil
+}
+
+// RegisterConfirmationsNtfn creates a new mock confirmation event.
+func (m *mockChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash,
+ pkScript []byte, numConfs, heightHint uint32,
+ opts ...chainntnfs.NotifierOption,
+) (*chainntnfs.ConfirmationEvent, error) {
+
+ mockEvent := &mockConfirmationEvent{
+ txid: *txid,
+ numConfs: numConfs,
+ confirmedChan: make(chan *chainntnfs.TxConfirmation, 1),
+ negConfChan: make(chan int32, 1),
+ }
+
+ m.confEvents = append(m.confEvents, mockEvent)
+
+ // Signal that a new confirmation event has been registered.
+ select {
+ case m.confRegistered <- struct{}{}:
+ default:
+ }
+
+ return &chainntnfs.ConfirmationEvent{
+ Confirmed: mockEvent.confirmedChan,
+ NegativeConf: mockEvent.negConfChan,
+ Cancel: func() {
+ mockEvent.cancelled = true
+ },
+ }, nil
+}
+
+// harnessOpt is a functional option for configuring the test harness.
+type harnessOpt func(*harnessConfig)
+
+// harnessConfig holds configuration for the test harness.
+type harnessConfig struct {
+ requiredConfs fn.Option[uint32]
+}
+
+// withRequiredConfs sets the number of confirmations required for channel
+// closes.
+func withRequiredConfs(confs uint32) harnessOpt {
+ return func(cfg *harnessConfig) {
+ cfg.requiredConfs = fn.Some(confs)
+ }
+}
+
+// newChainWatcherTestHarness creates a new test harness for chain watcher
+// tests.
+func newChainWatcherTestHarness(t *testing.T,
+ opts ...harnessOpt) *chainWatcherTestHarness {
+
+ return newChainWatcherTestHarnessFromReporter(t, t, opts...)
+}
+
+// newChainWatcherTestHarnessFromReporter creates a test harness that works
+// with both *testing.T and *rapid.T. The t parameter is used for
+// operations that specifically require *testing.T (like CreateTestChannels),
+// while reporter is used for all test reporting (Helper, Fatalf).
+func newChainWatcherTestHarnessFromReporter(t *testing.T,
+ reporter testReporter, opts ...harnessOpt) *chainWatcherTestHarness {
+
+ reporter.Helper()
+
+ // Apply options.
+ cfg := &harnessConfig{
+ requiredConfs: fn.None[uint32](),
+ }
+ for _, opt := range opts {
+ opt(cfg)
+ }
+
+ // Create test channels.
+ aliceChannel, bobChannel, err := lnwallet.CreateTestChannels(
+ t, channeldb.SingleFunderTweaklessBit,
+ )
+ if err != nil {
+ reporter.Fatalf("unable to create test channels: %v", err)
+ }
+
+ // Create mock notifier.
+ baseNotifier := &lnmock.ChainNotifier{
+ SpendChan: make(chan *chainntnfs.SpendDetail, 1),
+ EpochChan: make(chan *chainntnfs.BlockEpoch),
+ ConfChan: make(chan *chainntnfs.TxConfirmation, 1),
+ }
+
+ notifier := &mockChainNotifier{
+ ChainNotifier: baseNotifier,
+ confEvents: make([]*mockConfirmationEvent, 0),
+ confRegistered: make(chan struct{}, 10),
+ spendEvents: make([]*chainntnfs.SpendEvent, 0),
+ spendRegistered: make(chan struct{}, 10),
+ }
+
+ // Create chain watcher.
+ chainWatcher, err := newChainWatcher(chainWatcherConfig{
+ chanState: aliceChannel.State(),
+ notifier: notifier,
+ signer: aliceChannel.Signer,
+ extractStateNumHint: lnwallet.GetStateNumHint,
+ chanCloseConfs: cfg.requiredConfs,
+ contractBreach: func(
+ retInfo *lnwallet.BreachRetribution,
+ ) error {
+ // In tests, we just need to accept the breach
+ // notification.
+ return nil
+ },
+ })
+ if err != nil {
+ reporter.Fatalf("unable to create chain watcher: %v", err)
+ }
+
+ // Start chain watcher (this will register for spend notification).
+ err = chainWatcher.Start()
+ if err != nil {
+ reporter.Fatalf("unable to start chain watcher: %v", err)
+ }
+
+ // Subscribe to channel events.
+ chanEvents := chainWatcher.SubscribeChannelEvents()
+
+ harness := &chainWatcherTestHarness{
+ t: reporter,
+ aliceChannel: aliceChannel,
+ bobChannel: bobChannel,
+ chainWatcher: chainWatcher,
+ notifier: notifier,
+ chanEvents: chanEvents,
+ currentHeight: 100,
+ blockbeatProcessed: make(chan struct{}),
+ }
+
+ // Wait for the initial spend registration that happens in Start().
+ harness.waitForSpendRegistration()
+
+ // Verify BlockbeatChan is initialized.
+ if chainWatcher.BlockbeatChan == nil {
+ reporter.Fatalf("BlockbeatChan is nil after initialization")
+ }
+
+ // Register cleanup. We use the t for Cleanup since rapid.T
+ // may not have this method in the same way.
+ t.Cleanup(func() {
+ _ = chainWatcher.Stop()
+ })
+
+ return harness
+}
+
+// createCoopCloseTx creates a cooperative close transaction with the given
+// output value. The transaction will have the proper sequence number to
+// indicate it's a cooperative close.
+func (h *chainWatcherTestHarness) createCoopCloseTx(
+ outputValue int64) *wire.MsgTx {
+
+ fundingOutpoint := h.aliceChannel.State().FundingOutpoint
+
+ return &wire.MsgTx{
+ TxIn: []*wire.TxIn{{
+ PreviousOutPoint: fundingOutpoint,
+ Sequence: wire.MaxTxInSequenceNum,
+ }},
+ TxOut: []*wire.TxOut{{
+ Value: outputValue,
+ // Unique script.
+ PkScript: []byte{byte(outputValue % 255)},
+ }},
+ }
+}
+
+// createRemoteForceCloseTx creates a remote force close transaction.
+// From Alice's perspective, this is Bob's local commitment transaction.
+func (h *chainWatcherTestHarness) createRemoteForceCloseTx() *wire.MsgTx {
+ return h.bobChannel.State().LocalCommitment.CommitTx
+}
+
+// createLocalForceCloseTx creates a local force close transaction.
+// This is Alice's local commitment transaction.
+func (h *chainWatcherTestHarness) createLocalForceCloseTx() *wire.MsgTx {
+ return h.aliceChannel.State().LocalCommitment.CommitTx
+}
+
+// createBreachCloseTx creates a breach (revoked commitment) transaction.
+// We advance the channel state, save the commitment, then advance again
+// to revoke it. Returns the revoked commitment tx.
+func (h *chainWatcherTestHarness) createBreachCloseTx() *wire.MsgTx {
+ h.t.Helper()
+
+ // To create a revoked commitment, we need to advance the channel state
+ // at least once. We'll use the test utils helper to add an HTLC and
+ // force a state transition.
+
+ // Get the current commitment before we advance (this will be revoked).
+ revokedCommit := h.bobChannel.State().LocalCommitment.CommitTx
+
+ // Add a fake HTLC to advance state.
+ htlcAmount := lnwire.NewMSatFromSatoshis(10000)
+ paymentHash := [32]byte{4, 5, 6}
+ htlc := &lnwire.UpdateAddHTLC{
+ ID: 0,
+ Amount: htlcAmount,
+ Expiry: uint32(h.currentHeight + 100),
+ PaymentHash: paymentHash,
+ }
+
+ // Add HTLC to both channels.
+ if _, err := h.aliceChannel.AddHTLC(htlc, nil); err != nil {
+ h.t.Fatalf("unable to add HTLC to alice: %v", err)
+ }
+ if _, err := h.bobChannel.ReceiveHTLC(htlc); err != nil {
+ h.t.Fatalf("unable to add HTLC to bob: %v", err)
+ }
+
+ // Force state transition using the helper.
+ err := lnwallet.ForceStateTransition(h.aliceChannel, h.bobChannel)
+ if err != nil {
+ h.t.Fatalf("unable to force state transition: %v", err)
+ }
+
+ // Return the revoked commitment (Bob's previous local commitment).
+ return revokedCommit
+}
+
+// sendSpend sends a spend notification for the given transaction.
+func (h *chainWatcherTestHarness) sendSpend(tx *wire.MsgTx) {
+ h.t.Helper()
+
+ txHash := tx.TxHash()
+ spend := &chainntnfs.SpendDetail{
+ SpenderTxHash: &txHash,
+ SpendingTx: tx,
+ SpendingHeight: h.currentHeight,
+ }
+
+ select {
+ case h.notifier.SpendChan <- spend:
+ case <-time.After(time.Second):
+ h.t.Fatalf("unable to send spend notification")
+ }
+}
+
+// confirmTx sends a confirmation notification for the given transaction.
+func (h *chainWatcherTestHarness) confirmTx(tx *wire.MsgTx, height int32) {
+ h.t.Helper()
+
+ // Find the confirmation event for this transaction.
+ txHash := tx.TxHash()
+ var confEvent *mockConfirmationEvent
+ for _, event := range h.notifier.confEvents {
+ if event.txid == txHash && !event.cancelled {
+ confEvent = event
+ break
+ }
+ }
+
+ if confEvent == nil {
+ h.t.Fatalf("no confirmation event registered for tx %v", txHash)
+ }
+
+ // Send confirmation.
+ select {
+ case confEvent.confirmedChan <- &chainntnfs.TxConfirmation{
+ Tx: tx,
+ BlockHeight: uint32(height),
+ }:
+ case <-time.After(time.Second):
+ h.t.Fatalf("unable to send confirmation")
+ }
+}
+
+// triggerReorg sends a negative confirmation (reorg) notification for the
+// given transaction with the specified reorg depth.
+func (h *chainWatcherTestHarness) triggerReorg(tx *wire.MsgTx,
+ reorgDepth int32) {
+
+ h.t.Helper()
+
+ // Find the confirmation event for this transaction.
+ txHash := tx.TxHash()
+ var confEvent *mockConfirmationEvent
+ for _, event := range h.notifier.confEvents {
+ if event.txid == txHash && !event.cancelled {
+ confEvent = event
+ break
+ }
+ }
+
+ if confEvent == nil {
+ // The chain watcher might not have registered for
+ // confirmations yet.
+ return
+ }
+
+ // Send negative confirmation.
+ select {
+ case confEvent.negConfChan <- reorgDepth:
+ case <-time.After(time.Second):
+ h.t.Fatalf("unable to send negative confirmation")
+ }
+}
+
+// mineBlocks advances the current block height.
+func (h *chainWatcherTestHarness) mineBlocks(n int32) {
+ h.currentHeight += n
+}
+
+// waitForCoopClose waits for a cooperative close event and returns it.
+func (h *chainWatcherTestHarness) waitForCoopClose(
+ timeout time.Duration) *CooperativeCloseInfo {
+
+ h.t.Helper()
+
+ select {
+ case coopClose := <-h.chanEvents.CooperativeClosure:
+ return coopClose
+ case <-time.After(timeout):
+ h.t.Fatalf("didn't receive cooperative close event")
+ return nil
+ }
+}
+
+// waitForConfRegistration waits for the chain watcher to register for
+// confirmation notifications.
+func (h *chainWatcherTestHarness) waitForConfRegistration() {
+ h.t.Helper()
+
+ select {
+ case <-h.notifier.confRegistered:
+ // Registration complete.
+ case <-time.After(2 * time.Second):
+ // Not necessarily a failure - some tests don't register.
+ }
+}
+
+// waitForSpendRegistration waits for the chain watcher to register for
+// spend notifications.
+func (h *chainWatcherTestHarness) waitForSpendRegistration() {
+ h.t.Helper()
+
+ select {
+ case <-h.notifier.spendRegistered:
+ // Registration complete.
+ case <-time.After(2 * time.Second):
+ // Not necessarily a failure - some tests don't register.
+ }
+}
+
+// assertCoopCloseTx asserts that the given cooperative close info matches
+// the expected transaction.
+func (h *chainWatcherTestHarness) assertCoopCloseTx(
+ closeInfo *CooperativeCloseInfo, expectedTx *wire.MsgTx) {
+
+ h.t.Helper()
+
+ expectedHash := expectedTx.TxHash()
+ if closeInfo.ClosingTXID != expectedHash {
+ h.t.Fatalf("wrong tx confirmed: expected %v, got %v",
+ expectedHash, closeInfo.ClosingTXID)
+ }
+}
+
+// assertNoCoopClose asserts that no cooperative close event is received
+// within the given timeout.
+func (h *chainWatcherTestHarness) assertNoCoopClose(timeout time.Duration) {
+ h.t.Helper()
+
+ select {
+ case <-h.chanEvents.CooperativeClosure:
+ h.t.Fatalf("unexpected cooperative close event")
+ case <-time.After(timeout):
+ // Expected timeout.
+ }
+}
+
+// runCoopCloseFlow runs a complete cooperative close flow including spend,
+// optional reorg, and confirmation. This helper coordinates the timing
+// between the different events.
+func (h *chainWatcherTestHarness) runCoopCloseFlow(
+ tx *wire.MsgTx, shouldReorg bool, reorgDepth int32,
+ altTx *wire.MsgTx) *CooperativeCloseInfo {
+
+ h.t.Helper()
+
+ // Send initial spend notification. The closeObserver's state machine
+ // will detect this and register for confirmations.
+ h.sendSpend(tx)
+
+ // Wait for the chain watcher to register for confirmations.
+ h.waitForConfRegistration()
+
+ if shouldReorg {
+ // Trigger reorg which resets the state machine.
+ h.triggerReorg(tx, reorgDepth)
+
+ // If we have an alternative transaction, send it.
+ if altTx != nil {
+ // After reorg, the chain watcher should re-register for
+ // ANY spend of the funding output.
+ h.waitForSpendRegistration()
+
+ // Send alternative spend.
+ h.sendSpend(altTx)
+
+ // Wait for it to register for confirmations.
+ h.waitForConfRegistration()
+
+ // Confirm alternative transaction to unblock.
+ h.mineBlocks(1)
+ h.confirmTx(altTx, h.currentHeight)
+ }
+ } else {
+ // Normal confirmation flow - confirm to unblock
+ // waitForCoopCloseConfirmation.
+ h.mineBlocks(1)
+ h.confirmTx(tx, h.currentHeight)
+ }
+
+ // Wait for cooperative close event.
+ return h.waitForCoopClose(5 * time.Second)
+}
+
+// runMultipleReorgFlow simulates multiple consecutive reorganizations with
+// different transactions confirming after each reorg.
+func (h *chainWatcherTestHarness) runMultipleReorgFlow(txs []*wire.MsgTx,
+ reorgDepths []int32) *CooperativeCloseInfo {
+
+ h.t.Helper()
+
+ if len(txs) < 2 {
+ h.t.Fatalf("need at least 2 transactions for reorg flow")
+ }
+ if len(reorgDepths) != len(txs)-1 {
+ h.t.Fatalf("reorg depths must be one less than transactions")
+ }
+
+ // Send initial spend.
+ h.sendSpend(txs[0])
+
+ // Process each reorg.
+ for i, depth := range reorgDepths {
+ // Wait for confirmation registration.
+ h.waitForConfRegistration()
+
+ // Trigger reorg for current transaction.
+ h.triggerReorg(txs[i], depth)
+
+ // Wait for re-registration for spend.
+ h.waitForSpendRegistration()
+
+ // Send next transaction.
+ h.sendSpend(txs[i+1])
+ }
+
+ // Wait for final confirmation registration.
+ h.waitForConfRegistration()
+
+ // Confirm the final transaction.
+ finalTx := txs[len(txs)-1]
+ h.mineBlocks(1)
+ h.confirmTx(finalTx, h.currentHeight)
+
+ // Wait for cooperative close event.
+ return h.waitForCoopClose(10 * time.Second)
+}
+
+// waitForRemoteUnilateralClose waits for a remote unilateral close event.
+func (h *chainWatcherTestHarness) waitForRemoteUnilateralClose(
+ timeout time.Duration) *RemoteUnilateralCloseInfo {
+
+ h.t.Helper()
+
+ select {
+ case remoteClose := <-h.chanEvents.RemoteUnilateralClosure:
+ return remoteClose
+ case <-time.After(timeout):
+ h.t.Fatalf("didn't receive remote unilateral close event")
+ return nil
+ }
+}
+
+// waitForLocalUnilateralClose waits for a local unilateral close event.
+func (h *chainWatcherTestHarness) waitForLocalUnilateralClose(
+ timeout time.Duration) *LocalUnilateralCloseInfo {
+
+ h.t.Helper()
+
+ select {
+ case localClose := <-h.chanEvents.LocalUnilateralClosure:
+ return localClose
+ case <-time.After(timeout):
+ h.t.Fatalf("didn't receive local unilateral close event")
+ return nil
+ }
+}
+
+// waitForBreach waits for a breach (contract breach) event.
+func (h *chainWatcherTestHarness) waitForBreach(
+ timeout time.Duration) *BreachCloseInfo {
+
+ h.t.Helper()
+
+ select {
+ case breach := <-h.chanEvents.ContractBreach:
+ return breach
+ case <-time.After(timeout):
+ h.t.Fatalf("didn't receive contract breach event")
+ return nil
+ }
+}
+
+// assertRemoteUnilateralCloseTx asserts that the given remote unilateral close
+// info matches the expected transaction.
+func (h *chainWatcherTestHarness) assertRemoteUnilateralCloseTx(
+ closeInfo *RemoteUnilateralCloseInfo, expectedTx *wire.MsgTx) {
+
+ h.t.Helper()
+
+ expectedHash := expectedTx.TxHash()
+ actualHash := closeInfo.UnilateralCloseSummary.SpendDetail.SpenderTxHash
+ if *actualHash != expectedHash {
+ h.t.Fatalf("wrong tx confirmed: expected %v, got %v",
+ expectedHash, *actualHash)
+ }
+}
+
+// assertLocalUnilateralCloseTx asserts that the given local unilateral close
+// info matches the expected transaction.
+func (h *chainWatcherTestHarness) assertLocalUnilateralCloseTx(
+ closeInfo *LocalUnilateralCloseInfo, expectedTx *wire.MsgTx) {
+
+ h.t.Helper()
+
+ expectedHash := expectedTx.TxHash()
+ actualHash := closeInfo.LocalForceCloseSummary.CloseTx.TxHash()
+ if actualHash != expectedHash {
+ h.t.Fatalf("wrong tx confirmed: expected %v, got %v",
+ expectedHash, actualHash)
+ }
+}
+
+// assertBreachTx asserts that the given breach info matches the expected
+// transaction.
+func (h *chainWatcherTestHarness) assertBreachTx(
+ breachInfo *BreachCloseInfo, expectedTx *wire.MsgTx) {
+
+ h.t.Helper()
+
+ expectedHash := expectedTx.TxHash()
+ if breachInfo.CommitHash != expectedHash {
+ h.t.Fatalf("wrong tx confirmed: expected %v, got %v",
+ expectedHash, breachInfo.CommitHash)
+ }
+}
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.