contractcourt: add new chainWatcherTestHarness

We'll use this for all the upcoming tests.
This commit is contained in:
Olaoluwa Osuntokun committed 2026-01-15 16:22:26 -08:00
1 parent 0f59756b40
commit ce779a9707
1 file changed
+656
+656
View File
@@ -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)
}
}