diff --git a/chainio/README.md b/chainio/README.md
new file mode 100644
index 00000000000..b11e38157c2
--- /dev/null
+++ b/chainio/README.md
@@ -0,0 +1,152 @@
+# Chainio
+
+`chainio` is a package designed to provide blockchain data access to various
+subsystems within `lnd`. When a new block is received, it is encapsulated in a
+`Blockbeat` object and disseminated to all registered consumers. Consumers may
+receive these updates either concurrently or sequentially, based on their
+registration configuration, ensuring that each subsystem maintains a
+synchronized view of the current block state.
+
+The main components include:
+
+- `Blockbeat`: An interface that provides information about the block.
+
+- `Consumer`: An interface that specifies how subsystems handle the blockbeat.
+
+- `BlockbeatDispatcher`: The core service responsible for receiving each block
+ and distributing it to all consumers.
+
+Additionally, the `BeatConsumer` struct provides a partial implementation of
+the `Consumer` interface. This struct helps reduce code duplication, allowing
+subsystems to avoid re-implementing the `ProcessBlock` method and provides a
+commonly used `NotifyBlockProcessed` method.
+
+
+### Register a Consumer
+
+Consumers within the same queue are notified **sequentially**, while all queues
+are notified **concurrently**. A queue consists of a slice of consumers, which
+are notified in left-to-right order. Developers are responsible for determining
+dependencies in block consumption across subsystems: independent subsystems
+should be notified concurrently, whereas dependent subsystems should be
+notified sequentially.
+
+To notify the consumers concurrently, put them in different queues,
+```go
+// consumer1 and consumer2 will be notified concurrently.
+queue1 := []chainio.Consumer{consumer1}
+blockbeatDispatcher.RegisterQueue(consumer1)
+
+queue2 := []chainio.Consumer{consumer2}
+blockbeatDispatcher.RegisterQueue(consumer2)
+```
+
+To notify the consumers sequentially, put them in the same queue,
+```go
+// consumers will be notified sequentially via,
+// consumer1 -> consumer2 -> consumer3
+queue := []chainio.Consumer{
+ consumer1,
+ consumer2,
+ consumer3,
+}
+blockbeatDispatcher.RegisterQueue(queue)
+```
+
+### Implement the `Consumer` Interface
+
+Implementing the `Consumer` interface is straightforward. Below is an example
+of how
+[`sweep.TxPublisher`](https://github.com/lightningnetwork/lnd/blob/5cec466fad44c582a64cfaeb91f6d5fd302fcf85/sweep/fee_bumper.go#L310)
+implements this interface.
+
+To start, embed the partial implementation `chainio.BeatConsumer`, which
+already provides the `ProcessBlock` implementation and commonly used
+`NotifyBlockProcessed` method, and exposes `BlockbeatChan` for the consumer to
+receive blockbeats.
+
+```go
+type TxPublisher struct {
+ started atomic.Bool
+ stopped atomic.Bool
+
+ chainio.BeatConsumer
+
+ ...
+```
+
+We should also remember to initialize this `BeatConsumer`,
+
+```go
+...
+// Mount the block consumer.
+tp.BeatConsumer = chainio.NewBeatConsumer(tp.quit, tp.Name())
+```
+
+Finally, in the main event loop, read from `BlockbeatChan`, process the
+received blockbeat, and, crucially, call `tp.NotifyBlockProcessed` to inform
+the blockbeat dispatcher that processing is complete.
+
+```go
+for {
+ select {
+ case beat := <-tp.BlockbeatChan:
+ // Consume this blockbeat, usually it means updating the subsystem
+ // using the new block data.
+
+ // Notify we've processed the block.
+ tp.NotifyBlockProcessed(beat, nil)
+
+ ...
+```
+
+### Existing Queues
+
+Currently, we have a single queue of consumers dedicated to handling force
+closures. This queue includes `ChainArbitrator`, `UtxoSweeper`, and
+`TxPublisher`, with `ChainArbitrator` managing two internal consumers:
+`chainWatcher` and `ChannelArbitrator`. The blockbeat flows sequentially
+through the chain as follows: `ChainArbitrator => chainWatcher =>
+ChannelArbitrator => UtxoSweeper => TxPublisher`. The following diagram
+illustrates the flow within the public subsystems.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant bb as BlockBeat
+ participant cc as ChainArb
+ participant us as UtxoSweeper
+ participant tp as TxPublisher
+
+ note left of bb: 0. received block x,
dispatching...
+
+ note over bb,cc: 1. send block x to ChainArb,
wait for its done signal
+ bb->>cc: block x
+ rect rgba(165, 0, 85, 0.8)
+ critical signal processed
+ cc->>bb: processed block
+ option Process error or timeout
+ bb->>bb: error and exit
+ end
+ end
+
+ note over bb,us: 2. send block x to UtxoSweeper, wait for its done signal
+ bb->>us: block x
+ rect rgba(165, 0, 85, 0.8)
+ critical signal processed
+ us->>bb: processed block
+ option Process error or timeout
+ bb->>bb: error and exit
+ end
+ end
+
+ note over bb,tp: 3. send block x to TxPublisher, wait for its done signal
+ bb->>tp: block x
+ rect rgba(165, 0, 85, 0.8)
+ critical signal processed
+ tp->>bb: processed block
+ option Process error or timeout
+ bb->>bb: error and exit
+ end
+ end
+```
diff --git a/chainio/blockbeat.go b/chainio/blockbeat.go
new file mode 100644
index 00000000000..79188657fec
--- /dev/null
+++ b/chainio/blockbeat.go
@@ -0,0 +1,54 @@
+package chainio
+
+import (
+ "fmt"
+
+ "github.com/btcsuite/btclog/v2"
+ "github.com/lightningnetwork/lnd/chainntnfs"
+)
+
+// Beat implements the Blockbeat interface. It contains the block epoch and a
+// customized logger.
+//
+// TODO(yy): extend this to check for confirmation status - which serves as the
+// single source of truth, to avoid the potential race between receiving blocks
+// and `GetTransactionDetails/RegisterSpendNtfn/RegisterConfirmationsNtfn`.
+type Beat struct {
+ // epoch is the current block epoch the blockbeat is aware of.
+ epoch chainntnfs.BlockEpoch
+
+ // log is the customized logger for the blockbeat which prints the
+ // block height.
+ log btclog.Logger
+}
+
+// Compile-time check to ensure Beat satisfies the Blockbeat interface.
+var _ Blockbeat = (*Beat)(nil)
+
+// NewBeat creates a new beat with the specified block epoch and a customized
+// logger.
+func NewBeat(epoch chainntnfs.BlockEpoch) *Beat {
+ b := &Beat{
+ epoch: epoch,
+ }
+
+ // Create a customized logger for the blockbeat.
+ logPrefix := fmt.Sprintf("Height[%6d]:", b.Height())
+ b.log = clog.WithPrefix(logPrefix)
+
+ return b
+}
+
+// Height returns the height of the block epoch.
+//
+// NOTE: Part of the Blockbeat interface.
+func (b *Beat) Height() int32 {
+ return b.epoch.Height
+}
+
+// logger returns the logger for the blockbeat.
+//
+// NOTE: Part of the private blockbeat interface.
+func (b *Beat) logger() btclog.Logger {
+ return b.log
+}
diff --git a/chainio/blockbeat_test.go b/chainio/blockbeat_test.go
new file mode 100644
index 00000000000..9326651b38f
--- /dev/null
+++ b/chainio/blockbeat_test.go
@@ -0,0 +1,28 @@
+package chainio
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/lightningnetwork/lnd/chainntnfs"
+ "github.com/stretchr/testify/require"
+)
+
+var errDummy = errors.New("dummy error")
+
+// TestNewBeat tests the NewBeat and Height functions.
+func TestNewBeat(t *testing.T) {
+ t.Parallel()
+
+ // Create a testing epoch.
+ epoch := chainntnfs.BlockEpoch{
+ Height: 1,
+ }
+
+ // Create the beat and check the internal state.
+ beat := NewBeat(epoch)
+ require.Equal(t, epoch, beat.epoch)
+
+ // Check the height function.
+ require.Equal(t, epoch.Height, beat.Height())
+}
diff --git a/chainio/consumer.go b/chainio/consumer.go
new file mode 100644
index 00000000000..eb059a62450
--- /dev/null
+++ b/chainio/consumer.go
@@ -0,0 +1,113 @@
+package chainio
+
+// BeatConsumer defines a supplementary component that should be used by
+// subsystems which implement the `Consumer` interface. It partially implements
+// the `Consumer` interface by providing the method `ProcessBlock` such that
+// subsystems don't need to re-implement it.
+//
+// While inheritance is not commonly used in Go, subsystems embedding this
+// struct cannot pass the interface check for `Consumer` because the `Name`
+// method is not implemented, which gives us a "mortise and tenon" structure.
+// In addition to reducing code duplication, this design allows `ProcessBlock`
+// to work on the concrete type `Beat` to access its internal states.
+type BeatConsumer struct {
+ // BlockbeatChan is a channel to receive blocks from Blockbeat. The
+ // received block contains the best known height and the txns confirmed
+ // in this block.
+ BlockbeatChan chan Blockbeat
+
+ // name is the name of the consumer which embeds the BlockConsumer.
+ name string
+
+ // quit is a channel that closes when the BlockConsumer is shutting
+ // down.
+ //
+ // NOTE: this quit channel should be mounted to the same quit channel
+ // used by the subsystem.
+ quit chan struct{}
+
+ // errChan is a buffered chan that receives an error returned from
+ // processing this block.
+ errChan chan error
+}
+
+// NewBeatConsumer creates a new BlockConsumer.
+func NewBeatConsumer(quit chan struct{}, name string) BeatConsumer {
+ // Refuse to start `lnd` if the quit channel is not initialized. We
+ // treat this case as if we are facing a nil pointer dereference, as
+ // there's no point to return an error here, which will cause the node
+ // to fail to be started anyway.
+ if quit == nil {
+ panic("quit channel is nil")
+ }
+
+ b := BeatConsumer{
+ BlockbeatChan: make(chan Blockbeat),
+ name: name,
+ errChan: make(chan error, 1),
+ quit: quit,
+ }
+
+ return b
+}
+
+// ProcessBlock takes a blockbeat and sends it to the consumer's blockbeat
+// channel. It will send it to the subsystem's BlockbeatChan, and block until
+// the processed result is received from the subsystem. The subsystem must call
+// `NotifyBlockProcessed` after it has finished processing the block.
+//
+// NOTE: part of the `chainio.Consumer` interface.
+func (b *BeatConsumer) ProcessBlock(beat Blockbeat) error {
+ // Update the current height.
+ beat.logger().Tracef("set current height for [%s]", b.name)
+
+ select {
+ // Send the beat to the blockbeat channel. It's expected that the
+ // consumer will read from this channel and process the block. Once
+ // processed, it should return the error or nil to the beat.Err chan.
+ case b.BlockbeatChan <- beat:
+ beat.logger().Tracef("Sent blockbeat to [%s]", b.name)
+
+ case <-b.quit:
+ beat.logger().Debugf("[%s] received shutdown before sending "+
+ "beat", b.name)
+
+ return nil
+ }
+
+ // Check the consumer's err chan. We expect the consumer to call
+ // `beat.NotifyBlockProcessed` to send the error back here.
+ select {
+ case err := <-b.errChan:
+ beat.logger().Tracef("[%s] processed beat: err=%v", b.name, err)
+
+ return err
+
+ case <-b.quit:
+ beat.logger().Debugf("[%s] received shutdown", b.name)
+ }
+
+ return nil
+}
+
+// NotifyBlockProcessed signals that the block has been processed. It takes the
+// blockbeat being processed and an error resulted from processing it. This
+// error is then sent back to the consumer's err chan to unblock
+// `ProcessBlock`.
+//
+// NOTE: This method must be called by the subsystem after it has finished
+// processing the block.
+func (b *BeatConsumer) NotifyBlockProcessed(beat Blockbeat, err error) {
+ // Update the current height.
+ beat.logger().Tracef("[%s]: notifying beat processed", b.name)
+
+ select {
+ case b.errChan <- err:
+ beat.logger().Tracef("[%s]: notified beat processed, err=%v",
+ b.name, err)
+
+ case <-b.quit:
+ beat.logger().Debugf("[%s] received shutdown before notifying "+
+ "beat processed", b.name)
+ }
+}
diff --git a/chainio/consumer_test.go b/chainio/consumer_test.go
new file mode 100644
index 00000000000..d1cabf3168e
--- /dev/null
+++ b/chainio/consumer_test.go
@@ -0,0 +1,202 @@
+package chainio
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/stretchr/testify/require"
+)
+
+// TestNewBeatConsumer tests the NewBeatConsumer function.
+func TestNewBeatConsumer(t *testing.T) {
+ t.Parallel()
+
+ quitChan := make(chan struct{})
+ name := "test"
+
+ // Test the NewBeatConsumer function.
+ b := NewBeatConsumer(quitChan, name)
+
+ // Assert the state.
+ require.Equal(t, quitChan, b.quit)
+ require.Equal(t, name, b.name)
+ require.NotNil(t, b.BlockbeatChan)
+}
+
+// TestProcessBlockSuccess tests when the block is processed successfully, no
+// error is returned.
+func TestProcessBlockSuccess(t *testing.T) {
+ t.Parallel()
+
+ // Create a test consumer.
+ quitChan := make(chan struct{})
+ b := NewBeatConsumer(quitChan, "test")
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // Mock the consumer's err chan.
+ consumerErrChan := make(chan error, 1)
+ b.errChan = consumerErrChan
+
+ // Call the method under test.
+ resultChan := make(chan error, 1)
+ go func() {
+ resultChan <- b.ProcessBlock(mockBeat)
+ }()
+
+ // Assert the beat is sent to the blockbeat channel.
+ beat, err := fn.RecvOrTimeout(b.BlockbeatChan, time.Second)
+ require.NoError(t, err)
+ require.Equal(t, mockBeat, beat)
+
+ // Send nil to the consumer's error channel.
+ consumerErrChan <- nil
+
+ // Assert the result of ProcessBlock is nil.
+ result, err := fn.RecvOrTimeout(resultChan, time.Second)
+ require.NoError(t, err)
+ require.Nil(t, result)
+}
+
+// TestProcessBlockConsumerQuitBeforeSend tests when the consumer is quit
+// before sending the beat, the method returns immediately.
+func TestProcessBlockConsumerQuitBeforeSend(t *testing.T) {
+ t.Parallel()
+
+ // Create a test consumer.
+ quitChan := make(chan struct{})
+ b := NewBeatConsumer(quitChan, "test")
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // Call the method under test.
+ resultChan := make(chan error, 1)
+ go func() {
+ resultChan <- b.ProcessBlock(mockBeat)
+ }()
+
+ // Instead of reading the BlockbeatChan, close the quit channel.
+ close(quitChan)
+
+ // Assert ProcessBlock returned nil.
+ result, err := fn.RecvOrTimeout(resultChan, time.Second)
+ require.NoError(t, err)
+ require.Nil(t, result)
+}
+
+// TestProcessBlockConsumerQuitAfterSend tests when the consumer is quit after
+// sending the beat, the method returns immediately.
+func TestProcessBlockConsumerQuitAfterSend(t *testing.T) {
+ t.Parallel()
+
+ // Create a test consumer.
+ quitChan := make(chan struct{})
+ b := NewBeatConsumer(quitChan, "test")
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // Mock the consumer's err chan.
+ consumerErrChan := make(chan error, 1)
+ b.errChan = consumerErrChan
+
+ // Call the method under test.
+ resultChan := make(chan error, 1)
+ go func() {
+ resultChan <- b.ProcessBlock(mockBeat)
+ }()
+
+ // Assert the beat is sent to the blockbeat channel.
+ beat, err := fn.RecvOrTimeout(b.BlockbeatChan, time.Second)
+ require.NoError(t, err)
+ require.Equal(t, mockBeat, beat)
+
+ // Instead of sending nil to the consumer's error channel, close the
+ // quit chanel.
+ close(quitChan)
+
+ // Assert ProcessBlock returned nil.
+ result, err := fn.RecvOrTimeout(resultChan, time.Second)
+ require.NoError(t, err)
+ require.Nil(t, result)
+}
+
+// TestNotifyBlockProcessedSendErr asserts the error can be sent and read by
+// the beat via NotifyBlockProcessed.
+func TestNotifyBlockProcessedSendErr(t *testing.T) {
+ t.Parallel()
+
+ // Create a test consumer.
+ quitChan := make(chan struct{})
+ b := NewBeatConsumer(quitChan, "test")
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // Mock the consumer's err chan.
+ consumerErrChan := make(chan error, 1)
+ b.errChan = consumerErrChan
+
+ // Call the method under test.
+ done := make(chan error)
+ go func() {
+ defer close(done)
+ b.NotifyBlockProcessed(mockBeat, errDummy)
+ }()
+
+ // Assert the error is sent to the beat's err chan.
+ result, err := fn.RecvOrTimeout(consumerErrChan, time.Second)
+ require.NoError(t, err)
+ require.ErrorIs(t, result, errDummy)
+
+ // Assert the done channel is closed.
+ result, err = fn.RecvOrTimeout(done, time.Second)
+ require.NoError(t, err)
+ require.Nil(t, result)
+}
+
+// TestNotifyBlockProcessedOnQuit asserts NotifyBlockProcessed exits
+// immediately when the quit channel is closed.
+func TestNotifyBlockProcessedOnQuit(t *testing.T) {
+ t.Parallel()
+
+ // Create a test consumer.
+ quitChan := make(chan struct{})
+ b := NewBeatConsumer(quitChan, "test")
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // Mock the consumer's err chan - we don't buffer it so it will block
+ // on sending the error.
+ consumerErrChan := make(chan error)
+ b.errChan = consumerErrChan
+
+ // Call the method under test.
+ done := make(chan error)
+ go func() {
+ defer close(done)
+ b.NotifyBlockProcessed(mockBeat, errDummy)
+ }()
+
+ // Close the quit channel so the method will return.
+ close(b.quit)
+
+ // Assert the done channel is closed.
+ result, err := fn.RecvOrTimeout(done, time.Second)
+ require.NoError(t, err)
+ require.Nil(t, result)
+}
diff --git a/chainio/dispatcher.go b/chainio/dispatcher.go
new file mode 100644
index 00000000000..269bb1892c1
--- /dev/null
+++ b/chainio/dispatcher.go
@@ -0,0 +1,301 @@
+package chainio
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/btcsuite/btclog/v2"
+ "github.com/lightningnetwork/lnd/chainntnfs"
+ "github.com/lightningnetwork/lnd/lnutils"
+ "golang.org/x/sync/errgroup"
+)
+
+// DefaultProcessBlockTimeout is the timeout value used when waiting for one
+// consumer to finish processing the new block epoch.
+var DefaultProcessBlockTimeout = 60 * time.Second
+
+// ErrProcessBlockTimeout is the error returned when a consumer takes too long
+// to process the block.
+var ErrProcessBlockTimeout = errors.New("process block timeout")
+
+// BlockbeatDispatcher is a service that handles dispatching new blocks to
+// `lnd`'s subsystems. During startup, subsystems that are block-driven should
+// implement the `Consumer` interface and register themselves via
+// `RegisterQueue`. When two subsystems are independent of each other, they
+// should be registered in different queues so blocks are notified concurrently.
+// Otherwise, when living in the same queue, the subsystems are notified of the
+// new blocks sequentially, which means it's critical to understand the
+// relationship of these systems to properly handle the order.
+type BlockbeatDispatcher struct {
+ wg sync.WaitGroup
+
+ // notifier is used to receive new block epochs.
+ notifier chainntnfs.ChainNotifier
+
+ // beat is the latest blockbeat received.
+ beat Blockbeat
+
+ // consumerQueues is a map of consumers that will receive blocks. Its
+ // key is a unique counter and its value is a queue of consumers. Each
+ // queue is notified concurrently, and consumers in the same queue is
+ // notified sequentially.
+ consumerQueues map[uint32][]Consumer
+
+ // counter is used to assign a unique id to each queue.
+ counter atomic.Uint32
+
+ // quit is used to signal the BlockbeatDispatcher to stop.
+ quit chan struct{}
+}
+
+// NewBlockbeatDispatcher returns a new blockbeat dispatcher instance.
+func NewBlockbeatDispatcher(n chainntnfs.ChainNotifier) *BlockbeatDispatcher {
+ return &BlockbeatDispatcher{
+ notifier: n,
+ quit: make(chan struct{}),
+ consumerQueues: make(map[uint32][]Consumer),
+ }
+}
+
+// RegisterQueue takes a list of consumers and registers them in the same
+// queue.
+//
+// NOTE: these consumers are notified sequentially.
+func (b *BlockbeatDispatcher) RegisterQueue(consumers []Consumer) {
+ qid := b.counter.Add(1)
+
+ b.consumerQueues[qid] = append(b.consumerQueues[qid], consumers...)
+ clog.Infof("Registered queue=%d with %d blockbeat consumers", qid,
+ len(consumers))
+
+ for _, c := range consumers {
+ clog.Debugf("Consumer [%s] registered in queue %d", c.Name(),
+ qid)
+ }
+}
+
+// Start starts the blockbeat dispatcher - it registers a block notification
+// and monitors and dispatches new blocks in a goroutine. It will refuse to
+// start if there are no registered consumers.
+func (b *BlockbeatDispatcher) Start() error {
+ // Make sure consumers are registered.
+ if len(b.consumerQueues) == 0 {
+ return fmt.Errorf("no consumers registered")
+ }
+
+ // Start listening to new block epochs. We should get a notification
+ // with the current best block immediately.
+ blockEpochs, err := b.notifier.RegisterBlockEpochNtfn(nil)
+ if err != nil {
+ return fmt.Errorf("register block epoch ntfn: %w", err)
+ }
+
+ clog.Infof("BlockbeatDispatcher is starting with %d consumer queues",
+ len(b.consumerQueues))
+ defer clog.Debug("BlockbeatDispatcher started")
+
+ b.wg.Add(1)
+ go b.dispatchBlocks(blockEpochs)
+
+ return nil
+}
+
+// Stop shuts down the blockbeat dispatcher.
+func (b *BlockbeatDispatcher) Stop() {
+ clog.Info("BlockbeatDispatcher is stopping")
+ defer clog.Debug("BlockbeatDispatcher stopped")
+
+ // Signal the dispatchBlocks goroutine to stop.
+ close(b.quit)
+ b.wg.Wait()
+}
+
+func (b *BlockbeatDispatcher) log() btclog.Logger {
+ return b.beat.logger()
+}
+
+// dispatchBlocks listens to new block epoch and dispatches it to all the
+// consumers. Each queue is notified concurrently, and the consumers in the
+// same queue are notified sequentially.
+//
+// NOTE: Must be run as a goroutine.
+func (b *BlockbeatDispatcher) dispatchBlocks(
+ blockEpochs *chainntnfs.BlockEpochEvent) {
+
+ defer b.wg.Done()
+ defer blockEpochs.Cancel()
+
+ for {
+ select {
+ case blockEpoch, ok := <-blockEpochs.Epochs:
+ if !ok {
+ clog.Debugf("Block epoch channel closed")
+
+ return
+ }
+
+ // Log a separator so it's easier to identify when a
+ // new block arrives for subsystems.
+ clog.Debugf("%v", lnutils.NewSeparatorClosure())
+
+ clog.Infof("Received new block %v at height %d, "+
+ "notifying consumers...", blockEpoch.Hash,
+ blockEpoch.Height)
+
+ // Record the time it takes the consumer to process
+ // this block.
+ start := time.Now()
+
+ // Update the current block epoch.
+ b.beat = NewBeat(*blockEpoch)
+
+ // Notify all consumers.
+ err := b.notifyQueues()
+ if err != nil {
+ b.log().Errorf("Notify block failed: %v", err)
+ }
+
+ b.log().Infof("Notified all consumers on new block "+
+ "in %v", time.Since(start))
+
+ case <-b.quit:
+ b.log().Debugf("BlockbeatDispatcher quit signal " +
+ "received")
+
+ return
+ }
+ }
+}
+
+// notifyQueues notifies each queue concurrently about the latest block epoch.
+func (b *BlockbeatDispatcher) notifyQueues() error {
+ // errChans is a map of channels that will be used to receive errors
+ // returned from notifying the consumers.
+ errChans := make(map[uint32]chan error, len(b.consumerQueues))
+
+ // Notify each queue in goroutines.
+ for qid, consumers := range b.consumerQueues {
+ b.log().Debugf("Notifying queue=%d with %d consumers", qid,
+ len(consumers))
+
+ // Create a signal chan.
+ errChan := make(chan error, 1)
+ errChans[qid] = errChan
+
+ // Notify each queue concurrently.
+ go func(qid uint32, c []Consumer, beat Blockbeat) {
+ // Notify each consumer in this queue sequentially.
+ errChan <- DispatchSequential(beat, c)
+ }(qid, consumers, b.beat)
+ }
+
+ // Wait for all consumers in each queue to finish.
+ for qid, errChan := range errChans {
+ select {
+ case err := <-errChan:
+ if err != nil {
+ return fmt.Errorf("queue=%d got err: %w", qid,
+ err)
+ }
+
+ b.log().Debugf("Notified queue=%d", qid)
+
+ case <-b.quit:
+ b.log().Debugf("BlockbeatDispatcher quit signal " +
+ "received, exit notifyQueues")
+
+ return nil
+ }
+ }
+
+ return nil
+}
+
+// DispatchSequential takes a list of consumers and notify them about the new
+// epoch sequentially. It requires the consumer to finish processing the block
+// within the specified time, otherwise a timeout error is returned.
+func DispatchSequential(b Blockbeat, consumers []Consumer) error {
+ for _, c := range consumers {
+ // Send the beat to the consumer.
+ err := notifyAndWait(b, c, DefaultProcessBlockTimeout)
+ if err != nil {
+ b.logger().Errorf("Failed to process block: %v", err)
+
+ return err
+ }
+ }
+
+ return nil
+}
+
+// DispatchConcurrent notifies each consumer concurrently about the blockbeat.
+// It requires the consumer to finish processing the block within the specified
+// time, otherwise a timeout error is returned.
+func DispatchConcurrent(b Blockbeat, consumers []Consumer) error {
+ eg := &errgroup.Group{}
+
+ // Notify each queue in goroutines.
+ for _, c := range consumers {
+ // Notify each consumer concurrently.
+ eg.Go(func() error {
+ // Send the beat to the consumer.
+ err := notifyAndWait(b, c, DefaultProcessBlockTimeout)
+
+ // Exit early if there's no error.
+ if err == nil {
+ return nil
+ }
+
+ b.logger().Errorf("Consumer=%v failed to process "+
+ "block: %v", c.Name(), err)
+
+ return err
+ })
+ }
+
+ // Wait for all consumers in each queue to finish.
+ if err := eg.Wait(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// notifyAndWait sends the blockbeat to the specified consumer. It requires the
+// consumer to finish processing the block within the specified time, otherwise
+// a timeout error is returned.
+func notifyAndWait(b Blockbeat, c Consumer, timeout time.Duration) error {
+ b.logger().Debugf("Waiting for consumer[%s] to process it", c.Name())
+
+ // Record the time it takes the consumer to process this block.
+ start := time.Now()
+
+ errChan := make(chan error, 1)
+ go func() {
+ errChan <- c.ProcessBlock(b)
+ }()
+
+ // We expect the consumer to finish processing this block under 30s,
+ // otherwise a timeout error is returned.
+ select {
+ case err := <-errChan:
+ if err == nil {
+ break
+ }
+
+ return fmt.Errorf("%s got err in ProcessBlock: %w", c.Name(),
+ err)
+
+ case <-time.After(timeout):
+ return fmt.Errorf("consumer %s: %w", c.Name(),
+ ErrProcessBlockTimeout)
+ }
+
+ b.logger().Debugf("Consumer[%s] processed block in %v", c.Name(),
+ time.Since(start))
+
+ return nil
+}
diff --git a/chainio/dispatcher_test.go b/chainio/dispatcher_test.go
new file mode 100644
index 00000000000..11abbeb65e0
--- /dev/null
+++ b/chainio/dispatcher_test.go
@@ -0,0 +1,383 @@
+package chainio
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lightningnetwork/lnd/chainntnfs"
+ "github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+// TestNotifyAndWaitOnConsumerErr asserts when the consumer returns an error,
+// it's returned by notifyAndWait.
+func TestNotifyAndWaitOnConsumerErr(t *testing.T) {
+ t.Parallel()
+
+ // Create a mock consumer.
+ consumer := &MockConsumer{}
+ defer consumer.AssertExpectations(t)
+ consumer.On("Name").Return("mocker")
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // Mock ProcessBlock to return an error.
+ consumer.On("ProcessBlock", mockBeat).Return(errDummy).Once()
+
+ // Call the method under test.
+ err := notifyAndWait(mockBeat, consumer, DefaultProcessBlockTimeout)
+
+ // We expect the error to be returned.
+ require.ErrorIs(t, err, errDummy)
+}
+
+// TestNotifyAndWaitOnConsumerErr asserts when the consumer successfully
+// processed the beat, no error is returned.
+func TestNotifyAndWaitOnConsumerSuccess(t *testing.T) {
+ t.Parallel()
+
+ // Create a mock consumer.
+ consumer := &MockConsumer{}
+ defer consumer.AssertExpectations(t)
+ consumer.On("Name").Return("mocker")
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // Mock ProcessBlock to return nil.
+ consumer.On("ProcessBlock", mockBeat).Return(nil).Once()
+
+ // Call the method under test.
+ err := notifyAndWait(mockBeat, consumer, DefaultProcessBlockTimeout)
+
+ // We expect a nil error to be returned.
+ require.NoError(t, err)
+}
+
+// TestNotifyAndWaitOnConsumerTimeout asserts when the consumer times out
+// processing the block, the timeout error is returned.
+func TestNotifyAndWaitOnConsumerTimeout(t *testing.T) {
+ t.Parallel()
+
+ // Set timeout to be 10ms.
+ processBlockTimeout := 10 * time.Millisecond
+
+ // Create a mock consumer.
+ consumer := &MockConsumer{}
+ defer consumer.AssertExpectations(t)
+ consumer.On("Name").Return("mocker")
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // Mock ProcessBlock to return nil but blocks on returning.
+ consumer.On("ProcessBlock", mockBeat).Return(nil).Run(
+ func(args mock.Arguments) {
+ // Sleep one second to block on the method.
+ time.Sleep(processBlockTimeout * 100)
+ }).Once()
+
+ // Call the method under test.
+ err := notifyAndWait(mockBeat, consumer, processBlockTimeout)
+
+ // We expect a timeout error to be returned.
+ require.ErrorIs(t, err, ErrProcessBlockTimeout)
+}
+
+// TestDispatchSequential checks that the beat is sent to the consumers
+// sequentially.
+func TestDispatchSequential(t *testing.T) {
+ t.Parallel()
+
+ // Create three mock consumers.
+ consumer1 := &MockConsumer{}
+ defer consumer1.AssertExpectations(t)
+ consumer1.On("Name").Return("mocker1")
+
+ consumer2 := &MockConsumer{}
+ defer consumer2.AssertExpectations(t)
+ consumer2.On("Name").Return("mocker2")
+
+ consumer3 := &MockConsumer{}
+ defer consumer3.AssertExpectations(t)
+ consumer3.On("Name").Return("mocker3")
+
+ consumers := []Consumer{consumer1, consumer2, consumer3}
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // prevConsumer specifies the previous consumer that was called.
+ var prevConsumer string
+
+ // Mock the ProcessBlock on consumers to reutrn immediately.
+ consumer1.On("ProcessBlock", mockBeat).Return(nil).Run(
+ func(args mock.Arguments) {
+ // Check the order of the consumers.
+ //
+ // The first consumer should have no previous consumer.
+ require.Empty(t, prevConsumer)
+
+ // Set the consumer as the previous consumer.
+ prevConsumer = consumer1.Name()
+ }).Once()
+
+ consumer2.On("ProcessBlock", mockBeat).Return(nil).Run(
+ func(args mock.Arguments) {
+ // Check the order of the consumers.
+ //
+ // The second consumer should see consumer1.
+ require.Equal(t, consumer1.Name(), prevConsumer)
+
+ // Set the consumer as the previous consumer.
+ prevConsumer = consumer2.Name()
+ }).Once()
+
+ consumer3.On("ProcessBlock", mockBeat).Return(nil).Run(
+ func(args mock.Arguments) {
+ // Check the order of the consumers.
+ //
+ // The third consumer should see consumer2.
+ require.Equal(t, consumer2.Name(), prevConsumer)
+
+ // Set the consumer as the previous consumer.
+ prevConsumer = consumer3.Name()
+ }).Once()
+
+ // Call the method under test.
+ err := DispatchSequential(mockBeat, consumers)
+ require.NoError(t, err)
+
+ // Check the previous consumer is the last consumer.
+ require.Equal(t, consumer3.Name(), prevConsumer)
+}
+
+// TestRegisterQueue tests the RegisterQueue function.
+func TestRegisterQueue(t *testing.T) {
+ t.Parallel()
+
+ // Create two mock consumers.
+ consumer1 := &MockConsumer{}
+ defer consumer1.AssertExpectations(t)
+ consumer1.On("Name").Return("mocker1")
+
+ consumer2 := &MockConsumer{}
+ defer consumer2.AssertExpectations(t)
+ consumer2.On("Name").Return("mocker2")
+
+ consumers := []Consumer{consumer1, consumer2}
+
+ // Create a mock chain notifier.
+ mockNotifier := &chainntnfs.MockChainNotifier{}
+ defer mockNotifier.AssertExpectations(t)
+
+ // Create a new dispatcher.
+ b := NewBlockbeatDispatcher(mockNotifier)
+
+ // Register the consumers.
+ b.RegisterQueue(consumers)
+
+ // Assert that the consumers have been registered.
+ //
+ // We should have one queue.
+ require.Len(t, b.consumerQueues, 1)
+
+ // The queue should have two consumers.
+ queue, ok := b.consumerQueues[1]
+ require.True(t, ok)
+ require.Len(t, queue, 2)
+}
+
+// TestStartDispatcher tests the Start method.
+func TestStartDispatcher(t *testing.T) {
+ t.Parallel()
+
+ // Create a mock chain notifier.
+ mockNotifier := &chainntnfs.MockChainNotifier{}
+ defer mockNotifier.AssertExpectations(t)
+
+ // Create a new dispatcher.
+ b := NewBlockbeatDispatcher(mockNotifier)
+
+ // Start the dispatcher without consumers should return an error.
+ err := b.Start()
+ require.Error(t, err)
+
+ // Create a consumer and register it.
+ consumer := &MockConsumer{}
+ defer consumer.AssertExpectations(t)
+ consumer.On("Name").Return("mocker1")
+ b.RegisterQueue([]Consumer{consumer})
+
+ // Mock the chain notifier to return an error.
+ mockNotifier.On("RegisterBlockEpochNtfn",
+ mock.Anything).Return(nil, errDummy).Once()
+
+ // Start the dispatcher now should return the error.
+ err = b.Start()
+ require.ErrorIs(t, err, errDummy)
+
+ // Mock the chain notifier to return a valid notifier.
+ blockEpochs := &chainntnfs.BlockEpochEvent{}
+ mockNotifier.On("RegisterBlockEpochNtfn",
+ mock.Anything).Return(blockEpochs, nil).Once()
+
+ // Start the dispatcher now should not return an error.
+ err = b.Start()
+ require.NoError(t, err)
+}
+
+// TestDispatchBlocks asserts the blocks are properly dispatched to the queues.
+func TestDispatchBlocks(t *testing.T) {
+ t.Parallel()
+
+ // Create a mock chain notifier.
+ mockNotifier := &chainntnfs.MockChainNotifier{}
+ defer mockNotifier.AssertExpectations(t)
+
+ // Create a new dispatcher.
+ b := NewBlockbeatDispatcher(mockNotifier)
+
+ // Create the beat and attach it to the dispatcher.
+ epoch := chainntnfs.BlockEpoch{Height: 1}
+ beat := NewBeat(epoch)
+ b.beat = beat
+
+ // Create a consumer and register it.
+ consumer := &MockConsumer{}
+ defer consumer.AssertExpectations(t)
+ consumer.On("Name").Return("mocker1")
+ b.RegisterQueue([]Consumer{consumer})
+
+ // Mock the consumer to return nil error on ProcessBlock. This
+ // implictly asserts that the step `notifyQueues` is successfully
+ // reached in the `dispatchBlocks` method.
+ consumer.On("ProcessBlock", mock.Anything).Return(nil).Once()
+
+ // Create a test epoch chan.
+ epochChan := make(chan *chainntnfs.BlockEpoch, 1)
+ blockEpochs := &chainntnfs.BlockEpochEvent{
+ Epochs: epochChan,
+ Cancel: func() {},
+ }
+
+ // Call the method in a goroutine.
+ done := make(chan struct{})
+ b.wg.Add(1)
+ go func() {
+ defer close(done)
+ b.dispatchBlocks(blockEpochs)
+ }()
+
+ // Send an epoch.
+ epoch = chainntnfs.BlockEpoch{Height: 2}
+ epochChan <- &epoch
+
+ // Wait for the dispatcher to process the epoch.
+ time.Sleep(100 * time.Millisecond)
+
+ // Stop the dispatcher.
+ b.Stop()
+
+ // We expect the dispatcher to stop immediately.
+ _, err := fn.RecvOrTimeout(done, time.Second)
+ require.NoError(t, err)
+}
+
+// TestNotifyQueuesSuccess checks when the dispatcher successfully notifies all
+// the queues, no error is returned.
+func TestNotifyQueuesSuccess(t *testing.T) {
+ t.Parallel()
+
+ // Create two mock consumers.
+ consumer1 := &MockConsumer{}
+ defer consumer1.AssertExpectations(t)
+ consumer1.On("Name").Return("mocker1")
+
+ consumer2 := &MockConsumer{}
+ defer consumer2.AssertExpectations(t)
+ consumer2.On("Name").Return("mocker2")
+
+ // Create two queues.
+ queue1 := []Consumer{consumer1}
+ queue2 := []Consumer{consumer2}
+
+ // Create a mock chain notifier.
+ mockNotifier := &chainntnfs.MockChainNotifier{}
+ defer mockNotifier.AssertExpectations(t)
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // Create a new dispatcher.
+ b := NewBlockbeatDispatcher(mockNotifier)
+
+ // Register the queues.
+ b.RegisterQueue(queue1)
+ b.RegisterQueue(queue2)
+
+ // Attach the blockbeat.
+ b.beat = mockBeat
+
+ // Mock the consumers to return nil error on ProcessBlock for
+ // both calls.
+ consumer1.On("ProcessBlock", mockBeat).Return(nil).Once()
+ consumer2.On("ProcessBlock", mockBeat).Return(nil).Once()
+
+ // Notify the queues. The mockers will be asserted in the end to
+ // validate the calls.
+ err := b.notifyQueues()
+ require.NoError(t, err)
+}
+
+// TestNotifyQueuesError checks when one of the queue returns an error, this
+// error is returned by the method.
+func TestNotifyQueuesError(t *testing.T) {
+ t.Parallel()
+
+ // Create a mock consumer.
+ consumer := &MockConsumer{}
+ defer consumer.AssertExpectations(t)
+ consumer.On("Name").Return("mocker1")
+
+ // Create one queue.
+ queue := []Consumer{consumer}
+
+ // Create a mock chain notifier.
+ mockNotifier := &chainntnfs.MockChainNotifier{}
+ defer mockNotifier.AssertExpectations(t)
+
+ // Create a mock beat.
+ mockBeat := &MockBlockbeat{}
+ defer mockBeat.AssertExpectations(t)
+ mockBeat.On("logger").Return(clog)
+
+ // Create a new dispatcher.
+ b := NewBlockbeatDispatcher(mockNotifier)
+
+ // Register the queues.
+ b.RegisterQueue(queue)
+
+ // Attach the blockbeat.
+ b.beat = mockBeat
+
+ // Mock the consumer to return an error on ProcessBlock.
+ consumer.On("ProcessBlock", mockBeat).Return(errDummy).Once()
+
+ // Notify the queues. The mockers will be asserted in the end to
+ // validate the calls.
+ err := b.notifyQueues()
+ require.ErrorIs(t, err, errDummy)
+}
diff --git a/chainio/interface.go b/chainio/interface.go
new file mode 100644
index 00000000000..03c09faf7c0
--- /dev/null
+++ b/chainio/interface.go
@@ -0,0 +1,53 @@
+package chainio
+
+import "github.com/btcsuite/btclog/v2"
+
+// Blockbeat defines an interface that can be used by subsystems to retrieve
+// block data. It is sent by the BlockbeatDispatcher to all the registered
+// consumers whenever a new block is received. Once the consumer finishes
+// processing the block, it must signal it by calling `NotifyBlockProcessed`.
+//
+// The blockchain is a state machine - whenever there's a state change, it's
+// manifested in a block. The blockbeat is a way to notify subsystems of this
+// state change, and to provide them with the data they need to process it. In
+// other words, subsystems must react to this state change and should consider
+// being driven by the blockbeat in their own state machines.
+type Blockbeat interface {
+ // blockbeat is a private interface that's only used in this package.
+ blockbeat
+
+ // Height returns the current block height.
+ Height() int32
+}
+
+// blockbeat defines a set of private methods used in this package to make
+// interaction with the blockbeat easier.
+type blockbeat interface {
+ // logger returns the internal logger used by the blockbeat which has a
+ // block height prefix.
+ logger() btclog.Logger
+}
+
+// Consumer defines a blockbeat consumer interface. Subsystems that need block
+// info must implement it.
+type Consumer interface {
+ // TODO(yy): We should also define the start methods used by the
+ // consumers such that when implementing the interface, the consumer
+ // will always be started with a blockbeat. This cannot be enforced at
+ // the moment as we need refactor all the start methods to only take a
+ // beat.
+ //
+ // Start(beat Blockbeat) error
+
+ // Name returns a human-readable string for this subsystem.
+ Name() string
+
+ // ProcessBlock takes a blockbeat and processes it. It should not
+ // return until the subsystem has updated its state based on the block
+ // data.
+ //
+ // NOTE: The consumer must try its best to NOT return an error. If an
+ // error is returned from processing the block, it means the subsystem
+ // cannot react to onchain state changes and lnd will shutdown.
+ ProcessBlock(b Blockbeat) error
+}
diff --git a/chainio/log.go b/chainio/log.go
new file mode 100644
index 00000000000..2d8c26f7a59
--- /dev/null
+++ b/chainio/log.go
@@ -0,0 +1,32 @@
+package chainio
+
+import (
+ "github.com/btcsuite/btclog/v2"
+ "github.com/lightningnetwork/lnd/build"
+)
+
+// Subsystem defines the logging code for this subsystem.
+const Subsystem = "CHIO"
+
+// clog is a logger that is initialized with no output filters. This means the
+// package will not perform any logging by default until the caller requests
+// it.
+var clog btclog.Logger
+
+// The default amount of logging is none.
+func init() {
+ UseLogger(build.NewSubLogger(Subsystem, nil))
+}
+
+// DisableLog disables all library log output. Logging output is disabled by
+// default until UseLogger is called.
+func DisableLog() {
+ UseLogger(btclog.Disabled)
+}
+
+// UseLogger uses a specified Logger to output package logging info. This
+// should be used in preference to SetLogWriter if the caller is also using
+// btclog.
+func UseLogger(logger btclog.Logger) {
+ clog = logger
+}
diff --git a/chainio/mocks.go b/chainio/mocks.go
new file mode 100644
index 00000000000..5677734e1dd
--- /dev/null
+++ b/chainio/mocks.go
@@ -0,0 +1,50 @@
+package chainio
+
+import (
+ "github.com/btcsuite/btclog/v2"
+ "github.com/stretchr/testify/mock"
+)
+
+// MockConsumer is a mock implementation of the Consumer interface.
+type MockConsumer struct {
+ mock.Mock
+}
+
+// Compile-time constraint to ensure MockConsumer implements Consumer.
+var _ Consumer = (*MockConsumer)(nil)
+
+// Name returns a human-readable string for this subsystem.
+func (m *MockConsumer) Name() string {
+ args := m.Called()
+ return args.String(0)
+}
+
+// ProcessBlock takes a blockbeat and processes it. A receive-only error chan
+// must be returned.
+func (m *MockConsumer) ProcessBlock(b Blockbeat) error {
+ args := m.Called(b)
+
+ return args.Error(0)
+}
+
+// MockBlockbeat is a mock implementation of the Blockbeat interface.
+type MockBlockbeat struct {
+ mock.Mock
+}
+
+// Compile-time constraint to ensure MockBlockbeat implements Blockbeat.
+var _ Blockbeat = (*MockBlockbeat)(nil)
+
+// Height returns the current block height.
+func (m *MockBlockbeat) Height() int32 {
+ args := m.Called()
+
+ return args.Get(0).(int32)
+}
+
+// logger returns the logger for the blockbeat.
+func (m *MockBlockbeat) logger() btclog.Logger {
+ args := m.Called()
+
+ return args.Get(0).(btclog.Logger)
+}
diff --git a/chainntnfs/txnotifier.go b/chainntnfs/txnotifier.go
index 71b9c929df4..12366abf955 100644
--- a/chainntnfs/txnotifier.go
+++ b/chainntnfs/txnotifier.go
@@ -1757,10 +1757,6 @@ func (n *TxNotifier) NotifyHeight(height uint32) error {
for ntfn := range n.ntfnsByConfirmHeight[height] {
confSet := n.confNotifications[ntfn.ConfRequest]
- Log.Debugf("Dispatching %v confirmation notification for "+
- "conf_id=%v, %v", ntfn.NumConfirmations, ntfn.ConfID,
- ntfn.ConfRequest)
-
// The default notification we assigned above includes the
// block along with the rest of the details. However not all
// clients want the block, so we make a copy here w/o the block
@@ -1770,6 +1766,20 @@ func (n *TxNotifier) NotifyHeight(height uint32) error {
confDetails.Block = nil
}
+ // If the `confDetails` has already been sent before, we'll
+ // skip it and continue processing the next one.
+ if ntfn.dispatched {
+ Log.Debugf("Skipped dispatched conf details for "+
+ "request %v conf_id=%v", ntfn.ConfRequest,
+ ntfn.ConfID)
+
+ continue
+ }
+
+ Log.Debugf("Dispatching %v confirmation notification for "+
+ "conf_id=%v, %v", ntfn.NumConfirmations, ntfn.ConfID,
+ ntfn.ConfRequest)
+
select {
case ntfn.Event.Confirmed <- &confDetails:
ntfn.dispatched = true
diff --git a/chanrestore.go b/chanrestore.go
index 5b221c105a5..a041f571a8f 100644
--- a/chanrestore.go
+++ b/chanrestore.go
@@ -8,6 +8,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
+ "github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/chanbackup"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/contractcourt"
@@ -286,6 +287,9 @@ func (c *chanDBRestorer) RestoreChansFromSingles(backups ...chanbackup.Single) e
ltndLog.Infof("Informing chain watchers of new restored channels")
+ // Create a slice of channel points.
+ chanPoints := make([]wire.OutPoint, 0, len(channelShells))
+
// Finally, we'll need to inform the chain arbitrator of these new
// channels so we'll properly watch for their ultimate closure on chain
// and sweep them via the DLP.
@@ -294,8 +298,15 @@ func (c *chanDBRestorer) RestoreChansFromSingles(backups ...chanbackup.Single) e
if err != nil {
return err
}
+
+ chanPoints = append(
+ chanPoints, restoredChannel.Chan.FundingOutpoint,
+ )
}
+ // With all the channels restored, we'll now re-send the blockbeat.
+ c.chainArb.RedispatchBlockbeat(chanPoints)
+
return nil
}
@@ -314,7 +325,7 @@ func (s *server) ConnectPeer(nodePub *btcec.PublicKey, addrs []net.Addr) error {
// to ensure the new connection is created after this new link/channel
// is known.
if err := s.DisconnectPeer(nodePub); err != nil {
- ltndLog.Infof("Peer(%v) is already connected, proceeding "+
+ ltndLog.Infof("Peer(%x) is already connected, proceeding "+
"with chan restore", nodePub.SerializeCompressed())
}
diff --git a/contractcourt/anchor_resolver.go b/contractcourt/anchor_resolver.go
index e482c4c7132..f0f6e2f5a91 100644
--- a/contractcourt/anchor_resolver.go
+++ b/contractcourt/anchor_resolver.go
@@ -2,6 +2,7 @@ package contractcourt
import (
"errors"
+ "fmt"
"io"
"sync"
@@ -23,9 +24,6 @@ type anchorResolver struct {
// anchor is the outpoint on the commitment transaction.
anchor wire.OutPoint
- // resolved reflects if the contract has been fully resolved or not.
- resolved bool
-
// broadcastHeight is the height that the original contract was
// broadcast to the main-chain at. We'll use this value to bound any
// historical queries to the chain for spends/confirmations.
@@ -71,7 +69,7 @@ func newAnchorResolver(anchorSignDescriptor input.SignDescriptor,
currentReport: report,
}
- r.initLogger(r)
+ r.initLogger(fmt.Sprintf("%T(%v)", r, r.anchor))
return r
}
@@ -83,49 +81,14 @@ func (c *anchorResolver) ResolverKey() []byte {
return nil
}
-// Resolve offers the anchor output to the sweeper and waits for it to be swept.
-func (c *anchorResolver) Resolve(_ bool) (ContractResolver, error) {
- // Attempt to update the sweep parameters to the post-confirmation
- // situation. We don't want to force sweep anymore, because the anchor
- // lost its special purpose to get the commitment confirmed. It is just
- // an output that we want to sweep only if it is economical to do so.
- //
- // An exclusive group is not necessary anymore, because we know that
- // this is the only anchor that can be swept.
- //
- // We also clear the parent tx information for cpfp, because the
- // commitment tx is confirmed.
- //
- // After a restart or when the remote force closes, the sweeper is not
- // yet aware of the anchor. In that case, it will be added as new input
- // to the sweeper.
- witnessType := input.CommitmentAnchor
-
- // For taproot channels, we need to use the proper witness type.
- if c.chanType.IsTaproot() {
- witnessType = input.TaprootAnchorSweepSpend
- }
-
- anchorInput := input.MakeBaseInput(
- &c.anchor, witnessType, &c.anchorSignDescriptor,
- c.broadcastHeight, nil,
- )
-
- resultChan, err := c.Sweeper.SweepInput(
- &anchorInput,
- sweep.Params{
- // For normal anchor sweeping, the budget is 330 sats.
- Budget: btcutil.Amount(
- anchorInput.SignDesc().Output.Value,
- ),
-
- // There's no rush to sweep the anchor, so we use a nil
- // deadline here.
- DeadlineHeight: fn.None[int32](),
- },
- )
- if err != nil {
- return nil, err
+// Resolve waits for the output to be swept.
+//
+// NOTE: Part of the ContractResolver interface.
+func (c *anchorResolver) Resolve() (ContractResolver, error) {
+ // If we're already resolved, then we can exit early.
+ if c.IsResolved() {
+ c.log.Errorf("already resolved")
+ return nil, nil
}
var (
@@ -134,7 +97,7 @@ func (c *anchorResolver) Resolve(_ bool) (ContractResolver, error) {
)
select {
- case sweepRes := <-resultChan:
+ case sweepRes := <-c.sweepResultChan:
switch sweepRes.Err {
// Anchor was swept successfully.
case nil:
@@ -160,6 +123,8 @@ func (c *anchorResolver) Resolve(_ bool) (ContractResolver, error) {
return nil, errResolverShuttingDown
}
+ c.log.Infof("resolved in tx %v", spendTx)
+
// Update report to reflect that funds are no longer in limbo.
c.reportLock.Lock()
if outcome == channeldb.ResolverOutcomeClaimed {
@@ -171,7 +136,7 @@ func (c *anchorResolver) Resolve(_ bool) (ContractResolver, error) {
)
c.reportLock.Unlock()
- c.resolved = true
+ c.markResolved()
return nil, c.PutResolverReport(nil, report)
}
@@ -180,15 +145,10 @@ func (c *anchorResolver) Resolve(_ bool) (ContractResolver, error) {
//
// NOTE: Part of the ContractResolver interface.
func (c *anchorResolver) Stop() {
- close(c.quit)
-}
+ c.log.Debugf("stopping...")
+ defer c.log.Debugf("stopped")
-// IsResolved returns true if the stored state in the resolve is fully
-// resolved. In this case the target output can be forgotten.
-//
-// NOTE: Part of the ContractResolver interface.
-func (c *anchorResolver) IsResolved() bool {
- return c.resolved
+ close(c.quit)
}
// SupplementState allows the user of a ContractResolver to supplement it with
@@ -215,3 +175,68 @@ func (c *anchorResolver) Encode(w io.Writer) error {
// A compile time assertion to ensure anchorResolver meets the
// ContractResolver interface.
var _ ContractResolver = (*anchorResolver)(nil)
+
+// Launch offers the anchor output to the sweeper.
+func (c *anchorResolver) Launch() error {
+ if c.isLaunched() {
+ c.log.Tracef("already launched")
+ return nil
+ }
+
+ c.log.Debugf("launching resolver...")
+ c.markLaunched()
+
+ // If we're already resolved, then we can exit early.
+ if c.IsResolved() {
+ c.log.Errorf("already resolved")
+ return nil
+ }
+
+ // Attempt to update the sweep parameters to the post-confirmation
+ // situation. We don't want to force sweep anymore, because the anchor
+ // lost its special purpose to get the commitment confirmed. It is just
+ // an output that we want to sweep only if it is economical to do so.
+ //
+ // An exclusive group is not necessary anymore, because we know that
+ // this is the only anchor that can be swept.
+ //
+ // We also clear the parent tx information for cpfp, because the
+ // commitment tx is confirmed.
+ //
+ // After a restart or when the remote force closes, the sweeper is not
+ // yet aware of the anchor. In that case, it will be added as new input
+ // to the sweeper.
+ witnessType := input.CommitmentAnchor
+
+ // For taproot channels, we need to use the proper witness type.
+ if c.chanType.IsTaproot() {
+ witnessType = input.TaprootAnchorSweepSpend
+ }
+
+ anchorInput := input.MakeBaseInput(
+ &c.anchor, witnessType, &c.anchorSignDescriptor,
+ c.broadcastHeight, nil,
+ )
+
+ resultChan, err := c.Sweeper.SweepInput(
+ &anchorInput,
+ sweep.Params{
+ // For normal anchor sweeping, the budget is 330 sats.
+ Budget: btcutil.Amount(
+ anchorInput.SignDesc().Output.Value,
+ ),
+
+ // There's no rush to sweep the anchor, so we use a nil
+ // deadline here.
+ DeadlineHeight: fn.None[int32](),
+ },
+ )
+
+ if err != nil {
+ return err
+ }
+
+ c.sweepResultChan = resultChan
+
+ return nil
+}
diff --git a/contractcourt/breach_arbitrator_test.go b/contractcourt/breach_arbitrator_test.go
index c387c217970..99ed852696c 100644
--- a/contractcourt/breach_arbitrator_test.go
+++ b/contractcourt/breach_arbitrator_test.go
@@ -36,7 +36,7 @@ import (
)
var (
- defaultTimeout = 30 * time.Second
+ defaultTimeout = 10 * time.Second
breachOutPoints = []wire.OutPoint{
{
diff --git a/contractcourt/breach_resolver.go b/contractcourt/breach_resolver.go
index 740b4471d5d..f341128006c 100644
--- a/contractcourt/breach_resolver.go
+++ b/contractcourt/breach_resolver.go
@@ -2,6 +2,7 @@ package contractcourt
import (
"encoding/binary"
+ "fmt"
"io"
"github.com/lightningnetwork/lnd/channeldb"
@@ -11,9 +12,6 @@ import (
// future, this will likely take over the duties the current BreachArbitrator
// has.
type breachResolver struct {
- // resolved reflects if the contract has been fully resolved or not.
- resolved bool
-
// subscribed denotes whether or not the breach resolver has subscribed
// to the BreachArbitrator for breach resolution.
subscribed bool
@@ -32,7 +30,7 @@ func newBreachResolver(resCfg ResolverConfig) *breachResolver {
replyChan: make(chan struct{}),
}
- r.initLogger(r)
+ r.initLogger(fmt.Sprintf("%T(%v)", r, r.ChanPoint))
return r
}
@@ -46,8 +44,10 @@ func (b *breachResolver) ResolverKey() []byte {
// Resolve queries the BreachArbitrator to see if the justice transaction has
// been broadcast.
//
+// NOTE: Part of the ContractResolver interface.
+//
// TODO(yy): let sweeper handle the breach inputs.
-func (b *breachResolver) Resolve(_ bool) (ContractResolver, error) {
+func (b *breachResolver) Resolve() (ContractResolver, error) {
if !b.subscribed {
complete, err := b.SubscribeBreachComplete(
&b.ChanPoint, b.replyChan,
@@ -59,7 +59,7 @@ func (b *breachResolver) Resolve(_ bool) (ContractResolver, error) {
// If the breach resolution process is already complete, then
// we can cleanup and checkpoint the resolved state.
if complete {
- b.resolved = true
+ b.markResolved()
return nil, b.Checkpoint(b)
}
@@ -72,8 +72,9 @@ func (b *breachResolver) Resolve(_ bool) (ContractResolver, error) {
// The replyChan has been closed, signalling that the breach
// has been fully resolved. Checkpoint the resolved state and
// exit.
- b.resolved = true
+ b.markResolved()
return nil, b.Checkpoint(b)
+
case <-b.quit:
}
@@ -82,22 +83,17 @@ func (b *breachResolver) Resolve(_ bool) (ContractResolver, error) {
// Stop signals the breachResolver to stop.
func (b *breachResolver) Stop() {
+ b.log.Debugf("stopping...")
close(b.quit)
}
-// IsResolved returns true if the breachResolver is fully resolved and cleanup
-// can occur.
-func (b *breachResolver) IsResolved() bool {
- return b.resolved
-}
-
// SupplementState adds additional state to the breachResolver.
func (b *breachResolver) SupplementState(_ *channeldb.OpenChannel) {
}
// Encode encodes the breachResolver to the passed writer.
func (b *breachResolver) Encode(w io.Writer) error {
- return binary.Write(w, endian, b.resolved)
+ return binary.Write(w, endian, b.IsResolved())
}
// newBreachResolverFromReader attempts to decode an encoded breachResolver
@@ -110,11 +106,15 @@ func newBreachResolverFromReader(r io.Reader, resCfg ResolverConfig) (
replyChan: make(chan struct{}),
}
- if err := binary.Read(r, endian, &b.resolved); err != nil {
+ var resolved bool
+ if err := binary.Read(r, endian, &resolved); err != nil {
return nil, err
}
+ if resolved {
+ b.markResolved()
+ }
- b.initLogger(b)
+ b.initLogger(fmt.Sprintf("%T(%v)", b, b.ChanPoint))
return b, nil
}
@@ -122,3 +122,21 @@ func newBreachResolverFromReader(r io.Reader, resCfg ResolverConfig) (
// A compile time assertion to ensure breachResolver meets the ContractResolver
// interface.
var _ ContractResolver = (*breachResolver)(nil)
+
+// Launch offers the breach outputs to the sweeper - currently it's a NOOP as
+// the outputs here are not offered to the sweeper.
+//
+// NOTE: Part of the ContractResolver interface.
+//
+// TODO(yy): implement it once the outputs are offered to the sweeper.
+func (b *breachResolver) Launch() error {
+ if b.isLaunched() {
+ b.log.Tracef("already launched")
+ return nil
+ }
+
+ b.log.Debugf("launching resolver...")
+ b.markLaunched()
+
+ return nil
+}
diff --git a/contractcourt/briefcase.go b/contractcourt/briefcase.go
index 7d199c5c289..3e58147c615 100644
--- a/contractcourt/briefcase.go
+++ b/contractcourt/briefcase.go
@@ -249,6 +249,15 @@ func (a ArbitratorState) String() string {
}
}
+// IsContractClosed returns a bool to indicate whether the closing/breaching tx
+// has been confirmed onchain. If the state is StateContractClosed,
+// StateWaitingFullResolution, or StateFullyResolved, it means the contract has
+// been closed and all related contracts have been launched.
+func (a ArbitratorState) IsContractClosed() bool {
+ return a == StateContractClosed || a == StateWaitingFullResolution ||
+ a == StateFullyResolved
+}
+
// resolverType is an enum that enumerates the various types of resolvers. When
// writing resolvers to disk, we prepend this to the raw bytes stored. This
// allows us to properly decode the resolver into the proper type.
diff --git a/contractcourt/briefcase_test.go b/contractcourt/briefcase_test.go
index 533d0eff782..aa2e711efc2 100644
--- a/contractcourt/briefcase_test.go
+++ b/contractcourt/briefcase_test.go
@@ -206,8 +206,8 @@ func assertResolversEqual(t *testing.T, originalResolver ContractResolver,
ogRes.outputIncubating, diskRes.outputIncubating)
}
if ogRes.resolved != diskRes.resolved {
- t.Fatalf("expected %v, got %v", ogRes.resolved,
- diskRes.resolved)
+ t.Fatalf("expected %v, got %v", ogRes.resolved.Load(),
+ diskRes.resolved.Load())
}
if ogRes.broadcastHeight != diskRes.broadcastHeight {
t.Fatalf("expected %v, got %v",
@@ -229,8 +229,8 @@ func assertResolversEqual(t *testing.T, originalResolver ContractResolver,
ogRes.outputIncubating, diskRes.outputIncubating)
}
if ogRes.resolved != diskRes.resolved {
- t.Fatalf("expected %v, got %v", ogRes.resolved,
- diskRes.resolved)
+ t.Fatalf("expected %v, got %v", ogRes.resolved.Load(),
+ diskRes.resolved.Load())
}
if ogRes.broadcastHeight != diskRes.broadcastHeight {
t.Fatalf("expected %v, got %v",
@@ -275,8 +275,8 @@ func assertResolversEqual(t *testing.T, originalResolver ContractResolver,
ogRes.commitResolution, diskRes.commitResolution)
}
if ogRes.resolved != diskRes.resolved {
- t.Fatalf("expected %v, got %v", ogRes.resolved,
- diskRes.resolved)
+ t.Fatalf("expected %v, got %v", ogRes.resolved.Load(),
+ diskRes.resolved.Load())
}
if ogRes.broadcastHeight != diskRes.broadcastHeight {
t.Fatalf("expected %v, got %v",
@@ -312,13 +312,14 @@ func TestContractInsertionRetrieval(t *testing.T) {
SweepSignDesc: testSignDesc,
},
outputIncubating: true,
- resolved: true,
broadcastHeight: 102,
htlc: channeldb.HTLC{
HtlcIndex: 12,
},
}
- successResolver := htlcSuccessResolver{
+ timeoutResolver.resolved.Store(true)
+
+ successResolver := &htlcSuccessResolver{
htlcResolution: lnwallet.IncomingHtlcResolution{
Preimage: testPreimage,
SignedSuccessTx: nil,
@@ -327,40 +328,49 @@ func TestContractInsertionRetrieval(t *testing.T) {
SweepSignDesc: testSignDesc,
},
outputIncubating: true,
- resolved: true,
broadcastHeight: 109,
htlc: channeldb.HTLC{
RHash: testPreimage,
},
}
- resolvers := []ContractResolver{
- &timeoutResolver,
- &successResolver,
- &commitSweepResolver{
- commitResolution: lnwallet.CommitOutputResolution{
- SelfOutPoint: testChanPoint2,
- SelfOutputSignDesc: testSignDesc,
- MaturityDelay: 99,
- },
- resolved: false,
- broadcastHeight: 109,
- chanPoint: testChanPoint1,
+ successResolver.resolved.Store(true)
+
+ commitResolver := &commitSweepResolver{
+ commitResolution: lnwallet.CommitOutputResolution{
+ SelfOutPoint: testChanPoint2,
+ SelfOutputSignDesc: testSignDesc,
+ MaturityDelay: 99,
},
+ broadcastHeight: 109,
+ chanPoint: testChanPoint1,
+ }
+ commitResolver.resolved.Store(false)
+
+ resolvers := []ContractResolver{
+ &timeoutResolver, successResolver, commitResolver,
}
// All resolvers require a unique ResolverKey() output. To achieve this
// for the composite resolvers, we'll mutate the underlying resolver
// with a new outpoint.
- contestTimeout := timeoutResolver
- contestTimeout.htlcResolution.ClaimOutpoint = randOutPoint()
+ contestTimeout := htlcTimeoutResolver{
+ htlcResolution: lnwallet.OutgoingHtlcResolution{
+ ClaimOutpoint: randOutPoint(),
+ SweepSignDesc: testSignDesc,
+ },
+ }
resolvers = append(resolvers, &htlcOutgoingContestResolver{
htlcTimeoutResolver: &contestTimeout,
})
- contestSuccess := successResolver
- contestSuccess.htlcResolution.ClaimOutpoint = randOutPoint()
+ contestSuccess := &htlcSuccessResolver{
+ htlcResolution: lnwallet.IncomingHtlcResolution{
+ ClaimOutpoint: randOutPoint(),
+ SweepSignDesc: testSignDesc,
+ },
+ }
resolvers = append(resolvers, &htlcIncomingContestResolver{
htlcExpiry: 100,
- htlcSuccessResolver: &contestSuccess,
+ htlcSuccessResolver: contestSuccess,
})
// For quick lookup during the test, we'll create this map which allow
@@ -438,12 +448,12 @@ func TestContractResolution(t *testing.T) {
SweepSignDesc: testSignDesc,
},
outputIncubating: true,
- resolved: true,
broadcastHeight: 192,
htlc: channeldb.HTLC{
HtlcIndex: 9912,
},
}
+ timeoutResolver.resolved.Store(true)
// First, we'll insert the resolver into the database and ensure that
// we get the same resolver out the other side. We do not need to apply
@@ -491,12 +501,13 @@ func TestContractSwapping(t *testing.T) {
SweepSignDesc: testSignDesc,
},
outputIncubating: true,
- resolved: true,
broadcastHeight: 102,
htlc: channeldb.HTLC{
HtlcIndex: 12,
},
}
+ timeoutResolver.resolved.Store(true)
+
contestResolver := &htlcOutgoingContestResolver{
htlcTimeoutResolver: timeoutResolver,
}
diff --git a/contractcourt/chain_arbitrator.go b/contractcourt/chain_arbitrator.go
index 646d68b869e..83ae56c7a63 100644
--- a/contractcourt/chain_arbitrator.go
+++ b/contractcourt/chain_arbitrator.go
@@ -11,6 +11,7 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/walletdb"
+ "github.com/lightningnetwork/lnd/chainio"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/clock"
@@ -244,6 +245,10 @@ type ChainArbitrator struct {
started int32 // To be used atomically.
stopped int32 // To be used atomically.
+ // Embed the blockbeat consumer struct to get access to the method
+ // `NotifyBlockProcessed` and the `BlockbeatChan`.
+ chainio.BeatConsumer
+
sync.Mutex
// activeChannels is a map of all the active contracts that are still
@@ -262,6 +267,9 @@ type ChainArbitrator struct {
// active channels that it must still watch over.
chanSource *channeldb.DB
+ // beat is the current best known blockbeat.
+ beat chainio.Blockbeat
+
quit chan struct{}
wg sync.WaitGroup
@@ -272,15 +280,23 @@ type ChainArbitrator struct {
func NewChainArbitrator(cfg ChainArbitratorConfig,
db *channeldb.DB) *ChainArbitrator {
- return &ChainArbitrator{
+ c := &ChainArbitrator{
cfg: cfg,
activeChannels: make(map[wire.OutPoint]*ChannelArbitrator),
activeWatchers: make(map[wire.OutPoint]*chainWatcher),
chanSource: db,
quit: make(chan struct{}),
}
+
+ // Mount the block consumer.
+ c.BeatConsumer = chainio.NewBeatConsumer(c.quit, c.Name())
+
+ return c
}
+// Compile-time check for the chainio.Consumer interface.
+var _ chainio.Consumer = (*ChainArbitrator)(nil)
+
// arbChannel is a wrapper around an open channel that channel arbitrators
// interact with.
type arbChannel struct {
@@ -554,147 +570,27 @@ func (c *ChainArbitrator) ResolveContract(chanPoint wire.OutPoint) error {
}
// Start launches all goroutines that the ChainArbitrator needs to operate.
-func (c *ChainArbitrator) Start() error {
+func (c *ChainArbitrator) Start(beat chainio.Blockbeat) error {
if !atomic.CompareAndSwapInt32(&c.started, 0, 1) {
return nil
}
- log.Infof("ChainArbitrator starting with config: budget=[%v]",
- &c.cfg.Budget)
+ // Set the current beat.
+ c.beat = beat
// First, we'll fetch all the channels that are still open, in order to
// collect them within our set of active contracts.
- openChannels, err := c.chanSource.ChannelStateDB().FetchAllChannels()
- if err != nil {
+ if err := c.loadOpenChannels(); err != nil {
return err
}
- if len(openChannels) > 0 {
- log.Infof("Creating ChannelArbitrators for %v active channels",
- len(openChannels))
- }
-
- // For each open channel, we'll configure then launch a corresponding
- // ChannelArbitrator.
- for _, channel := range openChannels {
- chanPoint := channel.FundingOutpoint
- channel := channel
-
- // First, we'll create an active chainWatcher for this channel
- // to ensure that we detect any relevant on chain events.
- breachClosure := func(ret *lnwallet.BreachRetribution) error {
- return c.cfg.ContractBreach(chanPoint, ret)
- }
-
- chainWatcher, err := newChainWatcher(
- chainWatcherConfig{
- chanState: channel,
- notifier: c.cfg.Notifier,
- signer: c.cfg.Signer,
- isOurAddr: c.cfg.IsOurAddress,
- contractBreach: breachClosure,
- extractStateNumHint: lnwallet.GetStateNumHint,
- auxLeafStore: c.cfg.AuxLeafStore,
- auxResolver: c.cfg.AuxResolver,
- },
- )
- if err != nil {
- return err
- }
-
- c.activeWatchers[chanPoint] = chainWatcher
- channelArb, err := newActiveChannelArbitrator(
- channel, c, chainWatcher.SubscribeChannelEvents(),
- )
- if err != nil {
- return err
- }
-
- c.activeChannels[chanPoint] = channelArb
-
- // Republish any closing transactions for this channel.
- err = c.republishClosingTxs(channel)
- if err != nil {
- log.Errorf("Failed to republish closing txs for "+
- "channel %v", chanPoint)
- }
- }
-
// In addition to the channels that we know to be open, we'll also
// launch arbitrators to finishing resolving any channels that are in
// the pending close state.
- closingChannels, err := c.chanSource.ChannelStateDB().FetchClosedChannels(
- true,
- )
- if err != nil {
+ if err := c.loadPendingCloseChannels(); err != nil {
return err
}
- if len(closingChannels) > 0 {
- log.Infof("Creating ChannelArbitrators for %v closing channels",
- len(closingChannels))
- }
-
- // Next, for each channel is the closing state, we'll launch a
- // corresponding more restricted resolver, as we don't have to watch
- // the chain any longer, only resolve the contracts on the confirmed
- // commitment.
- //nolint:ll
- for _, closeChanInfo := range closingChannels {
- // We can leave off the CloseContract and ForceCloseChan
- // methods as the channel is already closed at this point.
- chanPoint := closeChanInfo.ChanPoint
- arbCfg := ChannelArbitratorConfig{
- ChanPoint: chanPoint,
- ShortChanID: closeChanInfo.ShortChanID,
- ChainArbitratorConfig: c.cfg,
- ChainEvents: &ChainEventSubscription{},
- IsPendingClose: true,
- ClosingHeight: closeChanInfo.CloseHeight,
- CloseType: closeChanInfo.CloseType,
- PutResolverReport: func(tx kvdb.RwTx,
- report *channeldb.ResolverReport) error {
-
- return c.chanSource.PutResolverReport(
- tx, c.cfg.ChainHash, &chanPoint, report,
- )
- },
- FetchHistoricalChannel: func() (*channeldb.OpenChannel, error) {
- chanStateDB := c.chanSource.ChannelStateDB()
- return chanStateDB.FetchHistoricalChannel(&chanPoint)
- },
- FindOutgoingHTLCDeadline: func(
- htlc channeldb.HTLC) fn.Option[int32] {
-
- return c.FindOutgoingHTLCDeadline(
- closeChanInfo.ShortChanID, htlc,
- )
- },
- }
- chanLog, err := newBoltArbitratorLog(
- c.chanSource.Backend, arbCfg, c.cfg.ChainHash, chanPoint,
- )
- if err != nil {
- return err
- }
- arbCfg.MarkChannelResolved = func() error {
- if c.cfg.NotifyFullyResolvedChannel != nil {
- c.cfg.NotifyFullyResolvedChannel(chanPoint)
- }
-
- return c.ResolveContract(chanPoint)
- }
-
- // We create an empty map of HTLC's here since it's possible
- // that the channel is in StateDefault and updateActiveHTLCs is
- // called. We want to avoid writing to an empty map. Since the
- // channel is already in the process of being resolved, no new
- // HTLCs will be added.
- c.activeChannels[chanPoint] = NewChannelArbitrator(
- arbCfg, make(map[HtlcSetKey]htlcSet), chanLog,
- )
- }
-
// Now, we'll start all chain watchers in parallel to shorten start up
// duration. In neutrino mode, this allows spend registrations to take
// advantage of batch spend reporting, instead of doing a single rescan
@@ -746,7 +642,7 @@ func (c *ChainArbitrator) Start() error {
// transaction.
var startStates map[wire.OutPoint]*chanArbStartState
- err = kvdb.View(c.chanSource, func(tx walletdb.ReadTx) error {
+ err := kvdb.View(c.chanSource, func(tx walletdb.ReadTx) error {
for _, arbitrator := range c.activeChannels {
startState, err := arbitrator.getStartState(tx)
if err != nil {
@@ -778,119 +674,45 @@ func (c *ChainArbitrator) Start() error {
arbitrator.cfg.ChanPoint)
}
- if err := arbitrator.Start(startState); err != nil {
+ if err := arbitrator.Start(startState, c.beat); err != nil {
stopAndLog()
return err
}
}
- // Subscribe to a single stream of block epoch notifications that we
- // will dispatch to all active arbitrators.
- blockEpoch, err := c.cfg.Notifier.RegisterBlockEpochNtfn(nil)
- if err != nil {
- return err
- }
-
// Start our goroutine which will dispatch blocks to each arbitrator.
c.wg.Add(1)
go func() {
defer c.wg.Done()
- c.dispatchBlocks(blockEpoch)
+ c.dispatchBlocks()
}()
+ log.Infof("ChainArbitrator starting at height %d with %d chain "+
+ "watchers, %d channel arbitrators, and budget config=[%v]",
+ c.beat.Height(), len(c.activeWatchers), len(c.activeChannels),
+ &c.cfg.Budget)
+
// TODO(roasbeef): eventually move all breach watching here
return nil
}
-// blockRecipient contains the information we need to dispatch a block to a
-// channel arbitrator.
-type blockRecipient struct {
- // chanPoint is the funding outpoint of the channel.
- chanPoint wire.OutPoint
-
- // blocks is the channel that new block heights are sent into. This
- // channel should be sufficiently buffered as to not block the sender.
- blocks chan<- int32
-
- // quit is closed if the receiving entity is shutting down.
- quit chan struct{}
-}
-
// dispatchBlocks consumes a block epoch notification stream and dispatches
// blocks to each of the chain arb's active channel arbitrators. This function
// must be run in a goroutine.
-func (c *ChainArbitrator) dispatchBlocks(
- blockEpoch *chainntnfs.BlockEpochEvent) {
-
- // getRecipients is a helper function which acquires the chain arb
- // lock and returns a set of block recipients which can be used to
- // dispatch blocks.
- getRecipients := func() []blockRecipient {
- c.Lock()
- blocks := make([]blockRecipient, 0, len(c.activeChannels))
- for _, channel := range c.activeChannels {
- blocks = append(blocks, blockRecipient{
- chanPoint: channel.cfg.ChanPoint,
- blocks: channel.blocks,
- quit: channel.quit,
- })
- }
- c.Unlock()
-
- return blocks
- }
-
- // On exit, cancel our blocks subscription and close each block channel
- // so that the arbitrators know they will no longer be receiving blocks.
- defer func() {
- blockEpoch.Cancel()
-
- recipients := getRecipients()
- for _, recipient := range recipients {
- close(recipient.blocks)
- }
- }()
-
+func (c *ChainArbitrator) dispatchBlocks() {
// Consume block epochs until we receive the instruction to shutdown.
for {
select {
// Consume block epochs, exiting if our subscription is
// terminated.
- case block, ok := <-blockEpoch.Epochs:
- if !ok {
- log.Trace("dispatchBlocks block epoch " +
- "cancelled")
- return
- }
+ case beat := <-c.BlockbeatChan:
+ // Set the current blockbeat.
+ c.beat = beat
- // Get the set of currently active channels block
- // subscription channels and dispatch the block to
- // each.
- for _, recipient := range getRecipients() {
- select {
- // Deliver the block to the arbitrator.
- case recipient.blocks <- block.Height:
-
- // If the recipient is shutting down, exit
- // without delivering the block. This may be
- // the case when two blocks are mined in quick
- // succession, and the arbitrator resolves
- // after the first block, and does not need to
- // consume the second block.
- case <-recipient.quit:
- log.Debugf("channel: %v exit without "+
- "receiving block: %v",
- recipient.chanPoint,
- block.Height)
-
- // If the chain arb is shutting down, we don't
- // need to deliver any more blocks (everything
- // will be shutting down).
- case <-c.quit:
- return
- }
- }
+ // Send this blockbeat to all the active channels and
+ // wait for them to finish processing it.
+ c.handleBlockbeat(beat)
// Exit if the chain arbitrator is shutting down.
case <-c.quit:
@@ -899,6 +721,47 @@ func (c *ChainArbitrator) dispatchBlocks(
}
}
+// handleBlockbeat sends the blockbeat to all active channel arbitrator in
+// parallel and wait for them to finish processing it.
+func (c *ChainArbitrator) handleBlockbeat(beat chainio.Blockbeat) {
+ // Read the active channels in a lock.
+ c.Lock()
+
+ // Create a slice to record active channel arbitrator.
+ channels := make([]chainio.Consumer, 0, len(c.activeChannels))
+ watchers := make([]chainio.Consumer, 0, len(c.activeWatchers))
+
+ // Copy the active channels to the slice.
+ for _, channel := range c.activeChannels {
+ channels = append(channels, channel)
+ }
+
+ for _, watcher := range c.activeWatchers {
+ watchers = append(watchers, watcher)
+ }
+
+ c.Unlock()
+
+ // Iterate all the copied watchers and send the blockbeat to them.
+ err := chainio.DispatchConcurrent(beat, watchers)
+ if err != nil {
+ log.Errorf("Notify blockbeat for chainWatcher failed: %v", err)
+ }
+
+ // Iterate all the copied channels and send the blockbeat to them.
+ //
+ // NOTE: This method will timeout if the processing of blocks of the
+ // subsystems is too long (60s).
+ err = chainio.DispatchConcurrent(beat, channels)
+ if err != nil {
+ log.Errorf("Notify blockbeat for ChannelArbitrator failed: %v",
+ err)
+ }
+
+ // Notify the chain arbitrator has processed the block.
+ c.NotifyBlockProcessed(beat, err)
+}
+
// republishClosingTxs will load any stored cooperative or unilateral closing
// transactions and republish them. This helps ensure propagation of the
// transactions in the event that prior publications failed.
@@ -1200,8 +1063,8 @@ func (c *ChainArbitrator) WatchNewChannel(newChan *channeldb.OpenChannel) error
chanPoint := newChan.FundingOutpoint
- log.Infof("Creating new ChannelArbitrator for ChannelPoint(%v)",
- chanPoint)
+ log.Infof("Creating new chainWatcher and ChannelArbitrator for "+
+ "ChannelPoint(%v)", chanPoint)
// If we're already watching this channel, then we'll ignore this
// request.
@@ -1248,7 +1111,7 @@ func (c *ChainArbitrator) WatchNewChannel(newChan *channeldb.OpenChannel) error
// arbitrators, then launch it.
c.activeChannels[chanPoint] = channelArb
- if err := channelArb.Start(nil); err != nil {
+ if err := channelArb.Start(nil, c.beat); err != nil {
return err
}
@@ -1361,3 +1224,192 @@ func (c *ChainArbitrator) FindOutgoingHTLCDeadline(scid lnwire.ShortChannelID,
// TODO(roasbeef): arbitration reports
// * types: contested, waiting for success conf, etc
+
+// NOTE: part of the `chainio.Consumer` interface.
+func (c *ChainArbitrator) Name() string {
+ return "ChainArbitrator"
+}
+
+// loadOpenChannels loads all channels that are currently open in the database
+// and registers them with the chainWatcher for future notification.
+func (c *ChainArbitrator) loadOpenChannels() error {
+ openChannels, err := c.chanSource.ChannelStateDB().FetchAllChannels()
+ if err != nil {
+ return err
+ }
+
+ if len(openChannels) == 0 {
+ return nil
+ }
+
+ log.Infof("Creating ChannelArbitrators for %v active channels",
+ len(openChannels))
+
+ // For each open channel, we'll configure then launch a corresponding
+ // ChannelArbitrator.
+ for _, channel := range openChannels {
+ chanPoint := channel.FundingOutpoint
+ channel := channel
+
+ // First, we'll create an active chainWatcher for this channel
+ // to ensure that we detect any relevant on chain events.
+ breachClosure := func(ret *lnwallet.BreachRetribution) error {
+ return c.cfg.ContractBreach(chanPoint, ret)
+ }
+
+ chainWatcher, err := newChainWatcher(
+ chainWatcherConfig{
+ chanState: channel,
+ notifier: c.cfg.Notifier,
+ signer: c.cfg.Signer,
+ isOurAddr: c.cfg.IsOurAddress,
+ contractBreach: breachClosure,
+ extractStateNumHint: lnwallet.GetStateNumHint,
+ auxLeafStore: c.cfg.AuxLeafStore,
+ auxResolver: c.cfg.AuxResolver,
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ c.activeWatchers[chanPoint] = chainWatcher
+ channelArb, err := newActiveChannelArbitrator(
+ channel, c, chainWatcher.SubscribeChannelEvents(),
+ )
+ if err != nil {
+ return err
+ }
+
+ c.activeChannels[chanPoint] = channelArb
+
+ // Republish any closing transactions for this channel.
+ err = c.republishClosingTxs(channel)
+ if err != nil {
+ log.Errorf("Failed to republish closing txs for "+
+ "channel %v", chanPoint)
+ }
+ }
+
+ return nil
+}
+
+// loadPendingCloseChannels loads all channels that are currently pending
+// closure in the database and registers them with the ChannelArbitrator to
+// continue the resolution process.
+func (c *ChainArbitrator) loadPendingCloseChannels() error {
+ chanStateDB := c.chanSource.ChannelStateDB()
+
+ closingChannels, err := chanStateDB.FetchClosedChannels(true)
+ if err != nil {
+ return err
+ }
+
+ if len(closingChannels) == 0 {
+ return nil
+ }
+
+ log.Infof("Creating ChannelArbitrators for %v closing channels",
+ len(closingChannels))
+
+ // Next, for each channel is the closing state, we'll launch a
+ // corresponding more restricted resolver, as we don't have to watch
+ // the chain any longer, only resolve the contracts on the confirmed
+ // commitment.
+ //nolint:ll
+ for _, closeChanInfo := range closingChannels {
+ // We can leave off the CloseContract and ForceCloseChan
+ // methods as the channel is already closed at this point.
+ chanPoint := closeChanInfo.ChanPoint
+ arbCfg := ChannelArbitratorConfig{
+ ChanPoint: chanPoint,
+ ShortChanID: closeChanInfo.ShortChanID,
+ ChainArbitratorConfig: c.cfg,
+ ChainEvents: &ChainEventSubscription{},
+ IsPendingClose: true,
+ ClosingHeight: closeChanInfo.CloseHeight,
+ CloseType: closeChanInfo.CloseType,
+ PutResolverReport: func(tx kvdb.RwTx,
+ report *channeldb.ResolverReport) error {
+
+ return c.chanSource.PutResolverReport(
+ tx, c.cfg.ChainHash, &chanPoint, report,
+ )
+ },
+ FetchHistoricalChannel: func() (*channeldb.OpenChannel, error) {
+ return chanStateDB.FetchHistoricalChannel(&chanPoint)
+ },
+ FindOutgoingHTLCDeadline: func(
+ htlc channeldb.HTLC) fn.Option[int32] {
+
+ return c.FindOutgoingHTLCDeadline(
+ closeChanInfo.ShortChanID, htlc,
+ )
+ },
+ }
+ chanLog, err := newBoltArbitratorLog(
+ c.chanSource.Backend, arbCfg, c.cfg.ChainHash, chanPoint,
+ )
+ if err != nil {
+ return err
+ }
+ arbCfg.MarkChannelResolved = func() error {
+ if c.cfg.NotifyFullyResolvedChannel != nil {
+ c.cfg.NotifyFullyResolvedChannel(chanPoint)
+ }
+
+ return c.ResolveContract(chanPoint)
+ }
+
+ // We create an empty map of HTLC's here since it's possible
+ // that the channel is in StateDefault and updateActiveHTLCs is
+ // called. We want to avoid writing to an empty map. Since the
+ // channel is already in the process of being resolved, no new
+ // HTLCs will be added.
+ c.activeChannels[chanPoint] = NewChannelArbitrator(
+ arbCfg, make(map[HtlcSetKey]htlcSet), chanLog,
+ )
+ }
+
+ return nil
+}
+
+// RedispatchBlockbeat resends the current blockbeat to the channels specified
+// by the chanPoints. It is used when a channel is added to the chain
+// arbitrator after it has been started, e.g., during the channel restore
+// process.
+func (c *ChainArbitrator) RedispatchBlockbeat(chanPoints []wire.OutPoint) {
+ // Get the current blockbeat.
+ beat := c.beat
+
+ // Prepare two sets of consumers.
+ channels := make([]chainio.Consumer, 0, len(chanPoints))
+ watchers := make([]chainio.Consumer, 0, len(chanPoints))
+
+ // Read the active channels in a lock.
+ c.Lock()
+ for _, op := range chanPoints {
+ if channel, ok := c.activeChannels[op]; ok {
+ channels = append(channels, channel)
+ }
+
+ if watcher, ok := c.activeWatchers[op]; ok {
+ watchers = append(watchers, watcher)
+ }
+ }
+ c.Unlock()
+
+ // Iterate all the copied watchers and send the blockbeat to them.
+ err := chainio.DispatchConcurrent(beat, watchers)
+ if err != nil {
+ log.Errorf("Notify blockbeat for chainWatcher failed: %v", err)
+ }
+
+ // Iterate all the copied channels and send the blockbeat to them.
+ err = chainio.DispatchConcurrent(beat, channels)
+ if err != nil {
+ // Shutdown lnd if there's an error processing the block.
+ log.Errorf("Notify blockbeat for ChannelArbitrator failed: %v",
+ err)
+ }
+}
diff --git a/contractcourt/chain_arbitrator_test.go b/contractcourt/chain_arbitrator_test.go
index fe2603ca5a6..622686f76c4 100644
--- a/contractcourt/chain_arbitrator_test.go
+++ b/contractcourt/chain_arbitrator_test.go
@@ -77,7 +77,6 @@ func TestChainArbitratorRepublishCloses(t *testing.T) {
ChainIO: &mock.ChainIO{},
Notifier: &mock.ChainNotifier{
SpendChan: make(chan *chainntnfs.SpendDetail),
- EpochChan: make(chan *chainntnfs.BlockEpoch),
ConfChan: make(chan *chainntnfs.TxConfirmation),
},
PublishTx: func(tx *wire.MsgTx, _ string) error {
@@ -91,7 +90,8 @@ func TestChainArbitratorRepublishCloses(t *testing.T) {
chainArbCfg, db,
)
- if err := chainArb.Start(); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chainArb.Start(beat); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
@@ -158,7 +158,6 @@ func TestResolveContract(t *testing.T) {
ChainIO: &mock.ChainIO{},
Notifier: &mock.ChainNotifier{
SpendChan: make(chan *chainntnfs.SpendDetail),
- EpochChan: make(chan *chainntnfs.BlockEpoch),
ConfChan: make(chan *chainntnfs.TxConfirmation),
},
PublishTx: func(tx *wire.MsgTx, _ string) error {
@@ -175,7 +174,8 @@ func TestResolveContract(t *testing.T) {
chainArb := NewChainArbitrator(
chainArbCfg, db,
)
- if err := chainArb.Start(); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chainArb.Start(beat); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go
index e29f21e7f45..fef70b945a1 100644
--- a/contractcourt/chain_watcher.go
+++ b/contractcourt/chain_watcher.go
@@ -16,6 +16,7 @@ import (
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/davecgh/go-spew/spew"
+ "github.com/lightningnetwork/lnd/chainio"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn/v2"
@@ -210,6 +211,10 @@ type chainWatcher struct {
started int32 // To be used atomically.
stopped int32 // To be used atomically.
+ // Embed the blockbeat consumer struct to get access to the method
+ // `NotifyBlockProcessed` and the `BlockbeatChan`.
+ chainio.BeatConsumer
+
quit chan struct{}
wg sync.WaitGroup
@@ -219,13 +224,6 @@ type chainWatcher struct {
// the current state number on the commitment transactions.
stateHintObfuscator [lnwallet.StateHintSize]byte
- // fundingPkScript is the pkScript of the funding output.
- fundingPkScript []byte
-
- // heightHint is the height hint used to checkpoint scans on chain for
- // conf/spend events.
- heightHint uint32
-
// All the fields below are protected by this mutex.
sync.Mutex
@@ -236,6 +234,22 @@ type chainWatcher struct {
// clientSubscriptions is a map that keeps track of all the active
// client subscriptions for events related to this channel.
clientSubscriptions map[uint64]*ChainEventSubscription
+
+ // fundingSpendNtfn is the spending notification subscription for the
+ // funding outpoint.
+ fundingSpendNtfn *chainntnfs.SpendEvent
+
+ // fundingConfirmedNtfn is the confirmation notification subscription
+ // for the funding outpoint. This is only created if the channel is
+ // both taproot and pending confirmation.
+ //
+ // For taproot pkscripts, `RegisterSpendNtfn` will only notify on the
+ // outpoint being spent and not the outpoint+pkscript due to
+ // `ComputePkScript` being unable to compute the pkscript if a key
+ // spend is used. We need to add a `RegisterConfirmationsNtfn` here to
+ // ensure that the outpoint+pkscript pair is confirmed before calling
+ // `RegisterSpendNtfn`.
+ fundingConfirmedNtfn *chainntnfs.ConfirmationEvent
}
// newChainWatcher returns a new instance of a chainWatcher for a channel given
@@ -260,12 +274,61 @@ func newChainWatcher(cfg chainWatcherConfig) (*chainWatcher, error) {
)
}
- return &chainWatcher{
+ // Get the witness script for the funding output.
+ fundingPkScript, err := deriveFundingPkScript(chanState)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get the channel opening block height.
+ heightHint := deriveHeightHint(chanState)
+
+ // We'll register for a notification to be dispatched if the funding
+ // output is spent.
+ spendNtfn, err := cfg.notifier.RegisterSpendNtfn(
+ &chanState.FundingOutpoint, fundingPkScript, heightHint,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ c := &chainWatcher{
cfg: cfg,
stateHintObfuscator: stateHint,
quit: make(chan struct{}),
clientSubscriptions: make(map[uint64]*ChainEventSubscription),
- }, nil
+ fundingSpendNtfn: spendNtfn,
+ }
+
+ // If this is a pending taproot channel, we need to register for a
+ // confirmation notification of the funding tx. Check the docs in
+ // `fundingConfirmedNtfn` for details.
+ if c.cfg.chanState.IsPending && c.cfg.chanState.ChanType.IsTaproot() {
+ confNtfn, err := cfg.notifier.RegisterConfirmationsNtfn(
+ &chanState.FundingOutpoint.Hash, fundingPkScript, 1,
+ heightHint,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ c.fundingConfirmedNtfn = confNtfn
+ }
+
+ // Mount the block consumer.
+ c.BeatConsumer = chainio.NewBeatConsumer(c.quit, c.Name())
+
+ return c, nil
+}
+
+// Compile-time check for the chainio.Consumer interface.
+var _ chainio.Consumer = (*chainWatcher)(nil)
+
+// Name returns the name of the watcher.
+//
+// NOTE: part of the `chainio.Consumer` interface.
+func (c *chainWatcher) Name() string {
+ return fmt.Sprintf("ChainWatcher(%v)", c.cfg.chanState.FundingOutpoint)
}
// Start starts all goroutines that the chainWatcher needs to perform its
@@ -275,75 +338,11 @@ func (c *chainWatcher) Start() error {
return nil
}
- chanState := c.cfg.chanState
log.Debugf("Starting chain watcher for ChannelPoint(%v)",
- chanState.FundingOutpoint)
-
- // First, we'll register for a notification to be dispatched if the
- // funding output is spent.
- fundingOut := &chanState.FundingOutpoint
-
- // As a height hint, we'll try to use the opening height, but if the
- // channel isn't yet open, then we'll use the height it was broadcast
- // at. This may be an unconfirmed zero-conf channel.
- c.heightHint = c.cfg.chanState.ShortChanID().BlockHeight
- if c.heightHint == 0 {
- c.heightHint = chanState.BroadcastHeight()
- }
-
- // Since no zero-conf state is stored in a channel backup, the below
- // logic will not be triggered for restored, zero-conf channels. Set
- // the height hint for zero-conf channels.
- if chanState.IsZeroConf() {
- if chanState.ZeroConfConfirmed() {
- // If the zero-conf channel is confirmed, we'll use the
- // confirmed SCID's block height.
- c.heightHint = chanState.ZeroConfRealScid().BlockHeight
- } else {
- // The zero-conf channel is unconfirmed. We'll need to
- // use the FundingBroadcastHeight.
- c.heightHint = chanState.BroadcastHeight()
- }
- }
-
- localKey := chanState.LocalChanCfg.MultiSigKey.PubKey
- remoteKey := chanState.RemoteChanCfg.MultiSigKey.PubKey
-
- var (
- err error
- )
- if chanState.ChanType.IsTaproot() {
- c.fundingPkScript, _, err = input.GenTaprootFundingScript(
- localKey, remoteKey, 0, chanState.TapscriptRoot,
- )
- if err != nil {
- return err
- }
- } else {
- multiSigScript, err := input.GenMultiSigScript(
- localKey.SerializeCompressed(),
- remoteKey.SerializeCompressed(),
- )
- if err != nil {
- return err
- }
- c.fundingPkScript, err = input.WitnessScriptHash(multiSigScript)
- if err != nil {
- return err
- }
- }
-
- spendNtfn, err := c.cfg.notifier.RegisterSpendNtfn(
- fundingOut, c.fundingPkScript, c.heightHint,
- )
- if err != nil {
- return err
- }
+ c.cfg.chanState.FundingOutpoint)
- // With the spend notification obtained, we'll now dispatch the
- // closeObserver which will properly react to any changes.
c.wg.Add(1)
- go c.closeObserver(spendNtfn)
+ go c.closeObserver()
return nil
}
@@ -555,7 +554,7 @@ func newChainSet(chanState *channeldb.OpenChannel) (*chainSet, error) {
localCommit, remoteCommit, err := chanState.LatestCommitments()
if err != nil {
return nil, fmt.Errorf("unable to fetch channel state for "+
- "chan_point=%v", chanState.FundingOutpoint)
+ "chan_point=%v: %v", chanState.FundingOutpoint, err)
}
log.Tracef("ChannelPoint(%v): local_commit_type=%v, local_commit=%v",
@@ -622,167 +621,49 @@ func newChainSet(chanState *channeldb.OpenChannel) (*chainSet, error) {
// close observer will assembled the proper materials required to claim the
// funds of the channel on-chain (if required), then dispatch these as
// notifications to all subscribers.
-func (c *chainWatcher) closeObserver(spendNtfn *chainntnfs.SpendEvent) {
+func (c *chainWatcher) closeObserver() {
defer c.wg.Done()
+ defer c.fundingSpendNtfn.Cancel()
log.Infof("Close observer for ChannelPoint(%v) active",
c.cfg.chanState.FundingOutpoint)
- // If this is a taproot channel, before we proceed, we want to ensure
- // that the expected funding output has confirmed on chain.
- if c.cfg.chanState.ChanType.IsTaproot() {
- fundingPoint := c.cfg.chanState.FundingOutpoint
-
- confNtfn, err := c.cfg.notifier.RegisterConfirmationsNtfn(
- &fundingPoint.Hash, c.fundingPkScript, 1, c.heightHint,
- )
- if err != nil {
- log.Warnf("unable to register for conf: %v", err)
- }
-
- log.Infof("Waiting for taproot ChannelPoint(%v) to confirm...",
- c.cfg.chanState.FundingOutpoint)
-
+ for {
select {
- case _, ok := <-confNtfn.Confirmed:
+ // A new block is received, we will check whether this block
+ // contains a spending tx that we are interested in.
+ case beat := <-c.BlockbeatChan:
+ log.Debugf("ChainWatcher(%v) received blockbeat %v",
+ c.cfg.chanState.FundingOutpoint, beat.Height())
+
+ // Process the block.
+ c.handleBlockbeat(beat)
+
+ // If the funding outpoint is spent, we now go ahead and handle
+ // it. Note that we cannot rely solely on the `block` event
+ // above to trigger a close event, as deep down, the receiving
+ // of block notifications and the receiving of spending
+ // notifications are done in two different goroutines, so the
+ // expected order: [receive block -> receive spend] is not
+ // guaranteed .
+ case spend, ok := <-c.fundingSpendNtfn.Spend:
// If the channel was closed, then this means that the
// notifier exited, so we will as well.
if !ok {
return
}
- case <-c.quit:
- return
- }
- }
-
- select {
- // We've detected a spend of the channel onchain! Depending on the type
- // of spend, we'll act accordingly, so we'll examine the spending
- // transaction to determine what we should do.
- //
- // TODO(Roasbeef): need to be able to ensure this only triggers
- // on confirmation, to ensure if multiple txns are broadcast, we
- // act on the one that's timestamped
- case commitSpend, ok := <-spendNtfn.Spend:
- // If the channel was closed, then this means that the notifier
- // exited, so we will as well.
- if !ok {
- return
- }
-
- // Otherwise, the remote party might have broadcast a prior
- // revoked state...!!!
- commitTxBroadcast := commitSpend.SpendingTx
-
- // First, we'll construct the chainset which includes all the
- // data we need to dispatch an event to our subscribers about
- // this possible channel close event.
- chainSet, err := newChainSet(c.cfg.chanState)
- if err != nil {
- log.Errorf("unable to create commit set: %v", err)
- return
- }
- // Decode the state hint encoded within the commitment
- // transaction to determine if this is a revoked state or not.
- obfuscator := c.stateHintObfuscator
- broadcastStateNum := c.cfg.extractStateNumHint(
- commitTxBroadcast, obfuscator,
- )
-
- // We'll go on to check whether it could be our own commitment
- // that was published and know is confirmed.
- ok, err = c.handleKnownLocalState(
- commitSpend, broadcastStateNum, chainSet,
- )
- if err != nil {
- log.Errorf("Unable to handle known local state: %v",
- err)
- return
- }
-
- if ok {
- return
- }
-
- // Now that we know it is neither a non-cooperative closure nor
- // a local close with the latest state, we check if it is the
- // remote that closed with any prior or current state.
- ok, err = c.handleKnownRemoteState(
- commitSpend, broadcastStateNum, chainSet,
- )
- if err != nil {
- log.Errorf("Unable to handle known remote state: %v",
- err)
- return
- }
-
- if ok {
- return
- }
-
- // Next, we'll check to see if this is a cooperative channel
- // closure or not. This is characterized by having an input
- // sequence number that's finalized. This won't happen with
- // regular commitment transactions due to the state hint
- // encoding scheme.
- switch commitTxBroadcast.TxIn[0].Sequence {
- case wire.MaxTxInSequenceNum:
- fallthrough
- case mempool.MaxRBFSequence:
- // TODO(roasbeef): rare but possible, need itest case
- // for
- err := c.dispatchCooperativeClose(commitSpend)
+ err := c.handleCommitSpend(spend)
if err != nil {
- log.Errorf("unable to handle co op close: %v", err)
+ log.Errorf("Failed to handle commit spend: %v",
+ err)
}
- return
- }
-
- log.Warnf("Unknown commitment broadcast for "+
- "ChannelPoint(%v) ", c.cfg.chanState.FundingOutpoint)
- // We'll try to recover as best as possible from losing state.
- // We first check if this was a local unknown state. This could
- // happen if we force close, then lose state or attempt
- // recovery before the commitment confirms.
- ok, err = c.handleUnknownLocalState(
- commitSpend, broadcastStateNum, chainSet,
- )
- if err != nil {
- log.Errorf("Unable to handle known local state: %v",
- err)
- return
- }
-
- if ok {
- return
- }
-
- // Since it was neither a known remote state, nor a local state
- // that was published, it most likely mean we lost state and
- // the remote node closed. In this case we must start the DLP
- // protocol in hope of getting our money back.
- ok, err = c.handleUnknownRemoteState(
- commitSpend, broadcastStateNum, chainSet,
- )
- if err != nil {
- log.Errorf("Unable to handle unknown remote state: %v",
- err)
- return
- }
-
- if ok {
+ // The chainWatcher has been signalled to exit, so we'll do so
+ // now.
+ case <-c.quit:
return
}
-
- log.Warnf("Unable to handle spending tx %v of channel point %v",
- commitTxBroadcast.TxHash(), c.cfg.chanState.FundingOutpoint)
- return
-
- // The chainWatcher has been signalled to exit, so we'll do so now.
- case <-c.quit:
- return
}
}
@@ -1412,3 +1293,263 @@ func (c *chainWatcher) waitForCommitmentPoint() *btcec.PublicKey {
}
}
}
+
+// deriveFundingPkScript derives the script used in the funding output.
+func deriveFundingPkScript(chanState *channeldb.OpenChannel) ([]byte, error) {
+ localKey := chanState.LocalChanCfg.MultiSigKey.PubKey
+ remoteKey := chanState.RemoteChanCfg.MultiSigKey.PubKey
+
+ var (
+ err error
+ fundingPkScript []byte
+ )
+
+ if chanState.ChanType.IsTaproot() {
+ fundingPkScript, _, err = input.GenTaprootFundingScript(
+ localKey, remoteKey, 0, chanState.TapscriptRoot,
+ )
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ multiSigScript, err := input.GenMultiSigScript(
+ localKey.SerializeCompressed(),
+ remoteKey.SerializeCompressed(),
+ )
+ if err != nil {
+ return nil, err
+ }
+ fundingPkScript, err = input.WitnessScriptHash(multiSigScript)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return fundingPkScript, nil
+}
+
+// deriveHeightHint derives the block height for the channel opening.
+func deriveHeightHint(chanState *channeldb.OpenChannel) uint32 {
+ // As a height hint, we'll try to use the opening height, but if the
+ // channel isn't yet open, then we'll use the height it was broadcast
+ // at. This may be an unconfirmed zero-conf channel.
+ heightHint := chanState.ShortChanID().BlockHeight
+ if heightHint == 0 {
+ heightHint = chanState.BroadcastHeight()
+ }
+
+ // Since no zero-conf state is stored in a channel backup, the below
+ // logic will not be triggered for restored, zero-conf channels. Set
+ // the height hint for zero-conf channels.
+ if chanState.IsZeroConf() {
+ if chanState.ZeroConfConfirmed() {
+ // If the zero-conf channel is confirmed, we'll use the
+ // confirmed SCID's block height.
+ heightHint = chanState.ZeroConfRealScid().BlockHeight
+ } else {
+ // The zero-conf channel is unconfirmed. We'll need to
+ // use the FundingBroadcastHeight.
+ heightHint = chanState.BroadcastHeight()
+ }
+ }
+
+ return heightHint
+}
+
+// handleCommitSpend takes a spending tx of the funding output and handles the
+// channel close based on the closure type.
+func (c *chainWatcher) handleCommitSpend(
+ commitSpend *chainntnfs.SpendDetail) error {
+
+ commitTxBroadcast := commitSpend.SpendingTx
+
+ // First, we'll construct the chainset which includes all the data we
+ // need to dispatch an event to our subscribers about this possible
+ // channel close event.
+ chainSet, err := newChainSet(c.cfg.chanState)
+ if err != nil {
+ return fmt.Errorf("create commit set: %w", err)
+ }
+
+ // Decode the state hint encoded within the commitment transaction to
+ // determine if this is a revoked state or not.
+ obfuscator := c.stateHintObfuscator
+ broadcastStateNum := c.cfg.extractStateNumHint(
+ commitTxBroadcast, obfuscator,
+ )
+
+ // We'll go on to check whether it could be our own commitment that was
+ // published and know is confirmed.
+ ok, err := c.handleKnownLocalState(
+ commitSpend, broadcastStateNum, chainSet,
+ )
+ if err != nil {
+ return fmt.Errorf("handle known local state: %w", err)
+ }
+ if ok {
+ return nil
+ }
+
+ // Now that we know it is neither a non-cooperative closure nor a local
+ // close with the latest state, we check if it is the remote that
+ // closed with any prior or current state.
+ ok, err = c.handleKnownRemoteState(
+ commitSpend, broadcastStateNum, chainSet,
+ )
+ if err != nil {
+ return fmt.Errorf("handle known remote state: %w", err)
+ }
+ if ok {
+ return nil
+ }
+
+ // Next, we'll check to see if this is a cooperative channel closure or
+ // not. This is characterized by having an input sequence number that's
+ // finalized. This won't happen with regular commitment transactions
+ // due to the state hint encoding scheme.
+ switch commitTxBroadcast.TxIn[0].Sequence {
+ case wire.MaxTxInSequenceNum:
+ fallthrough
+ case mempool.MaxRBFSequence:
+ // TODO(roasbeef): rare but possible, need itest case for
+ err := c.dispatchCooperativeClose(commitSpend)
+ if err != nil {
+ return fmt.Errorf("handle coop close: %w", err)
+ }
+
+ return nil
+ }
+
+ log.Warnf("Unknown commitment broadcast for ChannelPoint(%v) ",
+ c.cfg.chanState.FundingOutpoint)
+
+ // We'll try to recover as best as possible from losing state. We
+ // first check if this was a local unknown state. This could happen if
+ // we force close, then lose state or attempt recovery before the
+ // commitment confirms.
+ ok, err = c.handleUnknownLocalState(
+ commitSpend, broadcastStateNum, chainSet,
+ )
+ if err != nil {
+ return fmt.Errorf("handle known local state: %w", err)
+ }
+ if ok {
+ return nil
+ }
+
+ // Since it was neither a known remote state, nor a local state that
+ // was published, it most likely mean we lost state and the remote node
+ // closed. In this case we must start the DLP protocol in hope of
+ // getting our money back.
+ ok, err = c.handleUnknownRemoteState(
+ commitSpend, broadcastStateNum, chainSet,
+ )
+ if err != nil {
+ return fmt.Errorf("handle unknown remote state: %w", err)
+ }
+ if ok {
+ return nil
+ }
+
+ log.Errorf("Unable to handle spending tx %v of channel point %v",
+ commitTxBroadcast.TxHash(), c.cfg.chanState.FundingOutpoint)
+
+ return nil
+}
+
+// checkFundingSpend performs a non-blocking read on the spendNtfn channel to
+// check whether there's a commit spend already. Returns the spend details if
+// found.
+func (c *chainWatcher) checkFundingSpend() *chainntnfs.SpendDetail {
+ select {
+ // We've detected a spend of the channel onchain! Depending on the type
+ // of spend, we'll act accordingly, so we'll examine the spending
+ // transaction to determine what we should do.
+ //
+ // TODO(Roasbeef): need to be able to ensure this only triggers
+ // on confirmation, to ensure if multiple txns are broadcast, we
+ // act on the one that's timestamped
+ case spend, ok := <-c.fundingSpendNtfn.Spend:
+ // If the channel was closed, then this means that the notifier
+ // exited, so we will as well.
+ if !ok {
+ return nil
+ }
+
+ log.Debugf("Found spend details for funding output: %v",
+ spend.SpenderTxHash)
+
+ return spend
+
+ default:
+ }
+
+ return nil
+}
+
+// chanPointConfirmed checks whether the given channel point has confirmed.
+// This is used to ensure that the funding output has confirmed on chain before
+// we proceed with the rest of the close observer logic for taproot channels.
+// Check the docs in `fundingConfirmedNtfn` for details.
+func (c *chainWatcher) chanPointConfirmed() bool {
+ op := c.cfg.chanState.FundingOutpoint
+
+ select {
+ case _, ok := <-c.fundingConfirmedNtfn.Confirmed:
+ // If the channel was closed, then this means that the notifier
+ // exited, so we will as well.
+ if !ok {
+ return false
+ }
+
+ log.Debugf("Taproot ChannelPoint(%v) confirmed", op)
+
+ // The channel point has confirmed on chain. We now cancel the
+ // subscription.
+ c.fundingConfirmedNtfn.Cancel()
+
+ return true
+
+ default:
+ log.Infof("Taproot ChannelPoint(%v) not confirmed yet", op)
+
+ return false
+ }
+}
+
+// handleBlockbeat takes a blockbeat and queries for a spending tx for the
+// funding output. If the spending tx is found, it will be handled based on the
+// closure type.
+func (c *chainWatcher) handleBlockbeat(beat chainio.Blockbeat) {
+ // Notify the chain watcher has processed the block.
+ defer c.NotifyBlockProcessed(beat, nil)
+
+ // If we have a fundingConfirmedNtfn, it means this is a taproot
+ // channel that is pending, before we proceed, we want to ensure that
+ // the expected funding output has confirmed on chain. Check the docs
+ // in `fundingConfirmedNtfn` for details.
+ if c.fundingConfirmedNtfn != nil {
+ // If the funding output hasn't confirmed in this block, we
+ // will check it again in the next block.
+ if !c.chanPointConfirmed() {
+ return
+ }
+ }
+
+ // Perform a non-blocking read to check whether the funding output was
+ // spent.
+ spend := c.checkFundingSpend()
+ if spend == nil {
+ log.Tracef("No spend found for ChannelPoint(%v) in block %v",
+ c.cfg.chanState.FundingOutpoint, beat.Height())
+
+ return
+ }
+
+ // The funding output was spent, we now handle it by sending a close
+ // event to the channel arbitrator.
+ err := c.handleCommitSpend(spend)
+ if err != nil {
+ log.Errorf("Failed to handle commit spend: %v", err)
+ }
+}
diff --git a/contractcourt/chain_watcher_test.go b/contractcourt/chain_watcher_test.go
index baea8d8738c..0b3e23e714e 100644
--- a/contractcourt/chain_watcher_test.go
+++ b/contractcourt/chain_watcher_test.go
@@ -9,10 +9,11 @@ import (
"time"
"github.com/btcsuite/btcd/wire"
+ "github.com/lightningnetwork/lnd/chainio"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/input"
- "github.com/lightningnetwork/lnd/lntest/mock"
+ lnmock "github.com/lightningnetwork/lnd/lntest/mock"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
@@ -33,8 +34,8 @@ func TestChainWatcherRemoteUnilateralClose(t *testing.T) {
// With the channels created, we'll now create a chain watcher instance
// which will be watching for any closes of Alice's channel.
- aliceNotifier := &mock.ChainNotifier{
- SpendChan: make(chan *chainntnfs.SpendDetail),
+ aliceNotifier := &lnmock.ChainNotifier{
+ SpendChan: make(chan *chainntnfs.SpendDetail, 1),
EpochChan: make(chan *chainntnfs.BlockEpoch),
ConfChan: make(chan *chainntnfs.TxConfirmation),
}
@@ -49,6 +50,20 @@ func TestChainWatcherRemoteUnilateralClose(t *testing.T) {
require.NoError(t, err, "unable to start chain watcher")
defer aliceChainWatcher.Stop()
+ // Create a mock blockbeat and send it to Alice's BlockbeatChan.
+ mockBeat := &chainio.MockBlockbeat{}
+
+ // Mock the logger. We don't care how many times it's called as it's
+ // not critical.
+ mockBeat.On("logger").Return(log)
+
+ // Mock a fake block height - this is called based on the debuglevel.
+ mockBeat.On("Height").Return(int32(1)).Maybe()
+
+ // Mock `NotifyBlockProcessed` to be call once.
+ mockBeat.On("NotifyBlockProcessed",
+ nil, aliceChainWatcher.quit).Return().Once()
+
// We'll request a new channel event subscription from Alice's chain
// watcher.
chanEvents := aliceChainWatcher.SubscribeChannelEvents()
@@ -61,7 +76,19 @@ func TestChainWatcherRemoteUnilateralClose(t *testing.T) {
SpenderTxHash: &bobTxHash,
SpendingTx: bobCommit,
}
- aliceNotifier.SpendChan <- bobSpend
+
+ // Here we mock the behavior of a restart.
+ select {
+ case aliceNotifier.SpendChan <- bobSpend:
+ case <-time.After(1 * time.Second):
+ t.Fatalf("unable to send spend details")
+ }
+
+ select {
+ case aliceChainWatcher.BlockbeatChan <- mockBeat:
+ case <-time.After(time.Second * 1):
+ t.Fatalf("unable to send blockbeat")
+ }
// We should get a new spend event over the remote unilateral close
// event channel.
@@ -117,7 +144,7 @@ func TestChainWatcherRemoteUnilateralClosePendingCommit(t *testing.T) {
// With the channels created, we'll now create a chain watcher instance
// which will be watching for any closes of Alice's channel.
- aliceNotifier := &mock.ChainNotifier{
+ aliceNotifier := &lnmock.ChainNotifier{
SpendChan: make(chan *chainntnfs.SpendDetail),
EpochChan: make(chan *chainntnfs.BlockEpoch),
ConfChan: make(chan *chainntnfs.TxConfirmation),
@@ -165,7 +192,32 @@ func TestChainWatcherRemoteUnilateralClosePendingCommit(t *testing.T) {
SpenderTxHash: &bobTxHash,
SpendingTx: bobCommit,
}
- aliceNotifier.SpendChan <- bobSpend
+
+ // Create a mock blockbeat and send it to Alice's BlockbeatChan.
+ mockBeat := &chainio.MockBlockbeat{}
+
+ // Mock the logger. We don't care how many times it's called as it's
+ // not critical.
+ mockBeat.On("logger").Return(log)
+
+ // Mock a fake block height - this is called based on the debuglevel.
+ mockBeat.On("Height").Return(int32(1)).Maybe()
+
+ // Mock `NotifyBlockProcessed` to be call once.
+ mockBeat.On("NotifyBlockProcessed",
+ nil, aliceChainWatcher.quit).Return().Once()
+
+ select {
+ case aliceNotifier.SpendChan <- bobSpend:
+ case <-time.After(1 * time.Second):
+ t.Fatalf("unable to send spend details")
+ }
+
+ select {
+ case aliceChainWatcher.BlockbeatChan <- mockBeat:
+ case <-time.After(time.Second * 1):
+ t.Fatalf("unable to send blockbeat")
+ }
// We should get a new spend event over the remote unilateral close
// event channel.
@@ -279,7 +331,7 @@ func TestChainWatcherDataLossProtect(t *testing.T) {
// With the channels created, we'll now create a chain watcher
// instance which will be watching for any closes of Alice's
// channel.
- aliceNotifier := &mock.ChainNotifier{
+ aliceNotifier := &lnmock.ChainNotifier{
SpendChan: make(chan *chainntnfs.SpendDetail),
EpochChan: make(chan *chainntnfs.BlockEpoch),
ConfChan: make(chan *chainntnfs.TxConfirmation),
@@ -326,7 +378,34 @@ func TestChainWatcherDataLossProtect(t *testing.T) {
SpenderTxHash: &bobTxHash,
SpendingTx: bobCommit,
}
- aliceNotifier.SpendChan <- bobSpend
+
+ // Create a mock blockbeat and send it to Alice's
+ // BlockbeatChan.
+ mockBeat := &chainio.MockBlockbeat{}
+
+ // Mock the logger. We don't care how many times it's called as
+ // it's not critical.
+ mockBeat.On("logger").Return(log)
+
+ // Mock a fake block height - this is called based on the
+ // debuglevel.
+ mockBeat.On("Height").Return(int32(1)).Maybe()
+
+ // Mock `NotifyBlockProcessed` to be call once.
+ mockBeat.On("NotifyBlockProcessed",
+ nil, aliceChainWatcher.quit).Return().Once()
+
+ select {
+ case aliceNotifier.SpendChan <- bobSpend:
+ case <-time.After(time.Second * 1):
+ t.Fatalf("failed to send spend notification")
+ }
+
+ select {
+ case aliceChainWatcher.BlockbeatChan <- mockBeat:
+ case <-time.After(time.Second * 1):
+ t.Fatalf("unable to send blockbeat")
+ }
// We should get a new uni close resolution that indicates we
// processed the DLP scenario.
@@ -453,7 +532,7 @@ func TestChainWatcherLocalForceCloseDetect(t *testing.T) {
// With the channels created, we'll now create a chain watcher
// instance which will be watching for any closes of Alice's
// channel.
- aliceNotifier := &mock.ChainNotifier{
+ aliceNotifier := &lnmock.ChainNotifier{
SpendChan: make(chan *chainntnfs.SpendDetail),
EpochChan: make(chan *chainntnfs.BlockEpoch),
ConfChan: make(chan *chainntnfs.TxConfirmation),
@@ -497,7 +576,33 @@ func TestChainWatcherLocalForceCloseDetect(t *testing.T) {
SpenderTxHash: &aliceTxHash,
SpendingTx: aliceCommit,
}
- aliceNotifier.SpendChan <- aliceSpend
+ // Create a mock blockbeat and send it to Alice's
+ // BlockbeatChan.
+ mockBeat := &chainio.MockBlockbeat{}
+
+ // Mock the logger. We don't care how many times it's called as
+ // it's not critical.
+ mockBeat.On("logger").Return(log)
+
+ // Mock a fake block height - this is called based on the
+ // debuglevel.
+ mockBeat.On("Height").Return(int32(1)).Maybe()
+
+ // Mock `NotifyBlockProcessed` to be call once.
+ mockBeat.On("NotifyBlockProcessed",
+ nil, aliceChainWatcher.quit).Return().Once()
+
+ select {
+ case aliceNotifier.SpendChan <- aliceSpend:
+ case <-time.After(time.Second * 1):
+ t.Fatalf("unable to send spend notification")
+ }
+
+ select {
+ case aliceChainWatcher.BlockbeatChan <- mockBeat:
+ case <-time.After(time.Second * 1):
+ t.Fatalf("unable to send blockbeat")
+ }
// We should get a local force close event from Alice as she
// should be able to detect the close based on the commitment
diff --git a/contractcourt/channel_arbitrator.go b/contractcourt/channel_arbitrator.go
index 3404207b629..cfefea4573f 100644
--- a/contractcourt/channel_arbitrator.go
+++ b/contractcourt/channel_arbitrator.go
@@ -14,6 +14,7 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
+ "github.com/lightningnetwork/lnd/chainio"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
@@ -330,6 +331,10 @@ type ChannelArbitrator struct {
started int32 // To be used atomically.
stopped int32 // To be used atomically.
+ // Embed the blockbeat consumer struct to get access to the method
+ // `NotifyBlockProcessed` and the `BlockbeatChan`.
+ chainio.BeatConsumer
+
// startTimestamp is the time when this ChannelArbitrator was started.
startTimestamp time.Time
@@ -352,11 +357,6 @@ type ChannelArbitrator struct {
// to do its duty.
cfg ChannelArbitratorConfig
- // blocks is a channel that the arbitrator will receive new blocks on.
- // This channel should be buffered by so that it does not block the
- // sender.
- blocks chan int32
-
// signalUpdates is a channel that any new live signals for the channel
// we're watching over will be sent.
signalUpdates chan *signalUpdateMsg
@@ -404,9 +404,8 @@ func NewChannelArbitrator(cfg ChannelArbitratorConfig,
unmerged[RemotePendingHtlcSet] = htlcSets[RemotePendingHtlcSet]
}
- return &ChannelArbitrator{
+ c := &ChannelArbitrator{
log: log,
- blocks: make(chan int32, arbitratorBlockBufferSize),
signalUpdates: make(chan *signalUpdateMsg),
resolutionSignal: make(chan struct{}),
forceCloseReqs: make(chan *forceCloseReq),
@@ -415,8 +414,16 @@ func NewChannelArbitrator(cfg ChannelArbitratorConfig,
cfg: cfg,
quit: make(chan struct{}),
}
+
+ // Mount the block consumer.
+ c.BeatConsumer = chainio.NewBeatConsumer(c.quit, c.Name())
+
+ return c
}
+// Compile-time check for the chainio.Consumer interface.
+var _ chainio.Consumer = (*ChannelArbitrator)(nil)
+
// chanArbStartState contains the information from disk that we need to start
// up a channel arbitrator.
type chanArbStartState struct {
@@ -455,7 +462,9 @@ func (c *ChannelArbitrator) getStartState(tx kvdb.RTx) (*chanArbStartState,
// Start starts all the goroutines that the ChannelArbitrator needs to operate.
// If takes a start state, which will be looked up on disk if it is not
// provided.
-func (c *ChannelArbitrator) Start(state *chanArbStartState) error {
+func (c *ChannelArbitrator) Start(state *chanArbStartState,
+ beat chainio.Blockbeat) error {
+
if !atomic.CompareAndSwapInt32(&c.started, 0, 1) {
return nil
}
@@ -470,17 +479,15 @@ func (c *ChannelArbitrator) Start(state *chanArbStartState) error {
}
}
- log.Debugf("Starting ChannelArbitrator(%v), htlc_set=%v, state=%v",
+ log.Tracef("Starting ChannelArbitrator(%v), htlc_set=%v, state=%v",
c.cfg.ChanPoint, lnutils.SpewLogClosure(c.activeHTLCs),
state.currentState)
// Set our state from our starting state.
c.state = state.currentState
- _, bestHeight, err := c.cfg.ChainIO.GetBestBlock()
- if err != nil {
- return err
- }
+ // Get the starting height.
+ bestHeight := beat.Height()
c.wg.Add(1)
go c.channelAttendant(bestHeight, state.commitSet)
@@ -809,7 +816,7 @@ func (c *ChannelArbitrator) relaunchResolvers(commitSet *CommitSet,
// TODO(roasbeef): this isn't re-launched?
}
- c.launchResolvers(unresolvedContracts, true)
+ c.resolveContracts(unresolvedContracts)
return nil
}
@@ -1348,7 +1355,7 @@ func (c *ChannelArbitrator) stateStep(
// Finally, we'll launch all the required contract resolvers.
// Once they're all resolved, we're no longer needed.
- c.launchResolvers(resolvers, false)
+ c.resolveContracts(resolvers)
nextState = StateWaitingFullResolution
@@ -1571,17 +1578,72 @@ func (c *ChannelArbitrator) findCommitmentDeadlineAndValue(heightHint uint32,
return fn.Some(int32(deadline)), valueLeft, nil
}
-// launchResolvers updates the activeResolvers list and starts the resolvers.
-func (c *ChannelArbitrator) launchResolvers(resolvers []ContractResolver,
- immediate bool) {
-
+// resolveContracts updates the activeResolvers list and starts to resolve each
+// contract concurrently, and launches them.
+func (c *ChannelArbitrator) resolveContracts(resolvers []ContractResolver) {
c.activeResolversLock.Lock()
- defer c.activeResolversLock.Unlock()
-
c.activeResolvers = resolvers
+ c.activeResolversLock.Unlock()
+
+ // Launch all resolvers.
+ c.launchResolvers()
+
for _, contract := range resolvers {
c.wg.Add(1)
- go c.resolveContract(contract, immediate)
+ go c.resolveContract(contract)
+ }
+}
+
+// launchResolvers launches all the active resolvers concurrently.
+func (c *ChannelArbitrator) launchResolvers() {
+ c.activeResolversLock.Lock()
+ resolvers := c.activeResolvers
+ c.activeResolversLock.Unlock()
+
+ // errChans is a map of channels that will be used to receive errors
+ // returned from launching the resolvers.
+ errChans := make(map[ContractResolver]chan error, len(resolvers))
+
+ // Launch each resolver in goroutines.
+ for _, r := range resolvers {
+ // If the contract is already resolved, there's no need to
+ // launch it again.
+ if r.IsResolved() {
+ log.Debugf("ChannelArbitrator(%v): skipping resolver "+
+ "%T as it's already resolved", c.cfg.ChanPoint,
+ r)
+
+ continue
+ }
+
+ // Create a signal chan.
+ errChan := make(chan error, 1)
+ errChans[r] = errChan
+
+ go func() {
+ err := r.Launch()
+ errChan <- err
+ }()
+ }
+
+ // Wait for all resolvers to finish launching.
+ for r, errChan := range errChans {
+ select {
+ case err := <-errChan:
+ if err == nil {
+ continue
+ }
+
+ log.Errorf("ChannelArbitrator(%v): unable to launch "+
+ "contract resolver(%T): %v", c.cfg.ChanPoint, r,
+ err)
+
+ case <-c.quit:
+ log.Debugf("ChannelArbitrator quit signal received, " +
+ "exit launchResolvers")
+
+ return
+ }
}
}
@@ -1605,8 +1667,8 @@ func (c *ChannelArbitrator) advanceState(
for {
priorState = c.state
log.Debugf("ChannelArbitrator(%v): attempting state step with "+
- "trigger=%v from state=%v", c.cfg.ChanPoint, trigger,
- priorState)
+ "trigger=%v from state=%v at height=%v",
+ c.cfg.ChanPoint, trigger, priorState, triggerHeight)
nextState, closeTx, err := c.stateStep(
triggerHeight, trigger, confCommitSet,
@@ -2553,19 +2615,17 @@ func (c *ChannelArbitrator) replaceResolver(oldResolver,
// contracts.
//
// NOTE: This MUST be run as a goroutine.
-func (c *ChannelArbitrator) resolveContract(currentContract ContractResolver,
- immediate bool) {
-
+func (c *ChannelArbitrator) resolveContract(currentContract ContractResolver) {
defer c.wg.Done()
- log.Debugf("ChannelArbitrator(%v): attempting to resolve %T",
+ log.Tracef("ChannelArbitrator(%v): attempting to resolve %T",
c.cfg.ChanPoint, currentContract)
// Until the contract is fully resolved, we'll continue to iteratively
// resolve the contract one step at a time.
for !currentContract.IsResolved() {
- log.Debugf("ChannelArbitrator(%v): contract %T not yet resolved",
- c.cfg.ChanPoint, currentContract)
+ log.Tracef("ChannelArbitrator(%v): contract %T not yet "+
+ "resolved", c.cfg.ChanPoint, currentContract)
select {
@@ -2576,7 +2636,7 @@ func (c *ChannelArbitrator) resolveContract(currentContract ContractResolver,
default:
// Otherwise, we'll attempt to resolve the current
// contract.
- nextContract, err := currentContract.Resolve(immediate)
+ nextContract, err := currentContract.Resolve()
if err != nil {
if err == errResolverShuttingDown {
return
@@ -2625,6 +2685,13 @@ func (c *ChannelArbitrator) resolveContract(currentContract ContractResolver,
// loop.
currentContract = nextContract
+ // Launch the new contract.
+ err = currentContract.Launch()
+ if err != nil {
+ log.Errorf("Failed to launch %T: %v",
+ currentContract, err)
+ }
+
// If this contract is actually fully resolved, then
// we'll mark it as such within the database.
case currentContract.IsResolved():
@@ -2728,8 +2795,6 @@ func (c *ChannelArbitrator) updateActiveHTLCs() {
// Nursery for incubation, and ultimate sweeping.
//
// NOTE: This MUST be run as a goroutine.
-//
-//nolint:funlen
func (c *ChannelArbitrator) channelAttendant(bestHeight int32,
commitSet *CommitSet) {
@@ -2756,31 +2821,21 @@ func (c *ChannelArbitrator) channelAttendant(bestHeight int32,
// A new block has arrived, we'll examine all the active HTLC's
// to see if any of them have expired, and also update our
// track of the best current height.
- case blockHeight, ok := <-c.blocks:
- if !ok {
- return
- }
- bestHeight = blockHeight
+ case beat := <-c.BlockbeatChan:
+ bestHeight = beat.Height()
- // If we're not in the default state, then we can
- // ignore this signal as we're waiting for contract
- // resolution.
- if c.state != StateDefault {
- continue
- }
+ log.Debugf("ChannelArbitrator(%v): new block height=%v",
+ c.cfg.ChanPoint, bestHeight)
- // Now that a new block has arrived, we'll attempt to
- // advance our state forward.
- nextState, _, err := c.advanceState(
- uint32(bestHeight), chainTrigger, nil,
- )
+ err := c.handleBlockbeat(beat)
if err != nil {
- log.Errorf("Unable to advance state: %v", err)
+ log.Errorf("Handle block=%v got err: %v",
+ bestHeight, err)
}
// If as a result of this trigger, the contract is
// fully resolved, then well exit.
- if nextState == StateFullyResolved {
+ if c.state == StateFullyResolved {
return
}
@@ -2803,256 +2858,56 @@ func (c *ChannelArbitrator) channelAttendant(bestHeight int32,
// We've cooperatively closed the channel, so we're no longer
// needed. We'll mark the channel as resolved and exit.
case closeInfo := <-c.cfg.ChainEvents.CooperativeClosure:
- log.Infof("ChannelArbitrator(%v) marking channel "+
- "cooperatively closed", c.cfg.ChanPoint)
-
- err := c.cfg.MarkChannelClosed(
- closeInfo.ChannelCloseSummary,
- channeldb.ChanStatusCoopBroadcasted,
- )
+ err := c.handleCoopCloseEvent(closeInfo)
if err != nil {
- log.Errorf("Unable to mark channel closed: "+
- "%v", err)
- return
- }
+ log.Errorf("Failed to handle coop close: %v",
+ err)
- // We'll now advance our state machine until it reaches
- // a terminal state, and the channel is marked resolved.
- _, _, err = c.advanceState(
- closeInfo.CloseHeight, coopCloseTrigger, nil,
- )
- if err != nil {
- log.Errorf("Unable to advance state: %v", err)
return
}
// We have broadcasted our commitment, and it is now confirmed
// on-chain.
case closeInfo := <-c.cfg.ChainEvents.LocalUnilateralClosure:
- log.Infof("ChannelArbitrator(%v): local on-chain "+
- "channel close", c.cfg.ChanPoint)
-
if c.state != StateCommitmentBroadcasted {
log.Errorf("ChannelArbitrator(%v): unexpected "+
"local on-chain channel close",
c.cfg.ChanPoint)
}
- closeTx := closeInfo.CloseTx
- resolutions, err := closeInfo.ContractResolutions.
- UnwrapOrErr(
- fmt.Errorf("resolutions not found"),
- )
+ err := c.handleLocalForceCloseEvent(closeInfo)
if err != nil {
- log.Errorf("ChannelArbitrator(%v): unable to "+
- "get resolutions: %v", c.cfg.ChanPoint,
- err)
+ log.Errorf("Failed to handle local force "+
+ "close: %v", err)
return
}
- // We make sure that the htlc resolutions are present
- // otherwise we would panic dereferencing the pointer.
- //
- // TODO(ziggie): Refactor ContractResolutions to use
- // options.
- if resolutions.HtlcResolutions == nil {
- log.Errorf("ChannelArbitrator(%v): htlc "+
- "resolutions not found",
- c.cfg.ChanPoint)
-
- return
- }
-
- contractRes := &ContractResolutions{
- CommitHash: closeTx.TxHash(),
- CommitResolution: resolutions.CommitResolution,
- HtlcResolutions: *resolutions.HtlcResolutions,
- AnchorResolution: resolutions.AnchorResolution,
- }
-
- // When processing a unilateral close event, we'll
- // transition to the ContractClosed state. We'll log
- // out the set of resolutions such that they are
- // available to fetch in that state, we'll also write
- // the commit set so we can reconstruct our chain
- // actions on restart.
- err = c.log.LogContractResolutions(contractRes)
- if err != nil {
- log.Errorf("Unable to write resolutions: %v",
- err)
- return
- }
- err = c.log.InsertConfirmedCommitSet(
- &closeInfo.CommitSet,
- )
- if err != nil {
- log.Errorf("Unable to write commit set: %v",
- err)
- return
- }
-
- // After the set of resolutions are successfully
- // logged, we can safely close the channel. After this
- // succeeds we won't be getting chain events anymore,
- // so we must make sure we can recover on restart after
- // it is marked closed. If the next state transition
- // fails, we'll start up in the prior state again, and
- // we won't be longer getting chain events. In this
- // case we must manually re-trigger the state
- // transition into StateContractClosed based on the
- // close status of the channel.
- err = c.cfg.MarkChannelClosed(
- closeInfo.ChannelCloseSummary,
- channeldb.ChanStatusLocalCloseInitiator,
- )
- if err != nil {
- log.Errorf("Unable to mark "+
- "channel closed: %v", err)
- return
- }
-
- // We'll now advance our state machine until it reaches
- // a terminal state.
- _, _, err = c.advanceState(
- uint32(closeInfo.SpendingHeight),
- localCloseTrigger, &closeInfo.CommitSet,
- )
- if err != nil {
- log.Errorf("Unable to advance state: %v", err)
- }
-
// The remote party has broadcast the commitment on-chain.
// We'll examine our state to determine if we need to act at
// all.
case uniClosure := <-c.cfg.ChainEvents.RemoteUnilateralClosure:
- log.Infof("ChannelArbitrator(%v): remote party has "+
- "closed channel out on-chain", c.cfg.ChanPoint)
-
- // If we don't have a self output, and there are no
- // active HTLC's, then we can immediately mark the
- // contract as fully resolved and exit.
- contractRes := &ContractResolutions{
- CommitHash: *uniClosure.SpenderTxHash,
- CommitResolution: uniClosure.CommitResolution,
- HtlcResolutions: *uniClosure.HtlcResolutions,
- AnchorResolution: uniClosure.AnchorResolution,
- }
-
- // When processing a unilateral close event, we'll
- // transition to the ContractClosed state. We'll log
- // out the set of resolutions such that they are
- // available to fetch in that state, we'll also write
- // the commit set so we can reconstruct our chain
- // actions on restart.
- err := c.log.LogContractResolutions(contractRes)
- if err != nil {
- log.Errorf("Unable to write resolutions: %v",
- err)
- return
- }
- err = c.log.InsertConfirmedCommitSet(
- &uniClosure.CommitSet,
- )
+ err := c.handleRemoteForceCloseEvent(uniClosure)
if err != nil {
- log.Errorf("Unable to write commit set: %v",
- err)
- return
- }
+ log.Errorf("Failed to handle remote force "+
+ "close: %v", err)
- // After the set of resolutions are successfully
- // logged, we can safely close the channel. After this
- // succeeds we won't be getting chain events anymore,
- // so we must make sure we can recover on restart after
- // it is marked closed. If the next state transition
- // fails, we'll start up in the prior state again, and
- // we won't be longer getting chain events. In this
- // case we must manually re-trigger the state
- // transition into StateContractClosed based on the
- // close status of the channel.
- closeSummary := &uniClosure.ChannelCloseSummary
- err = c.cfg.MarkChannelClosed(
- closeSummary,
- channeldb.ChanStatusRemoteCloseInitiator,
- )
- if err != nil {
- log.Errorf("Unable to mark channel closed: %v",
- err)
return
}
- // We'll now advance our state machine until it reaches
- // a terminal state.
- _, _, err = c.advanceState(
- uint32(uniClosure.SpendingHeight),
- remoteCloseTrigger, &uniClosure.CommitSet,
- )
- if err != nil {
- log.Errorf("Unable to advance state: %v", err)
- }
-
// The remote has breached the channel. As this is handled by
// the ChainWatcher and BreachArbitrator, we don't have to do
// anything in particular, so just advance our state and
// gracefully exit.
case breachInfo := <-c.cfg.ChainEvents.ContractBreach:
- log.Infof("ChannelArbitrator(%v): remote party has "+
- "breached channel!", c.cfg.ChanPoint)
-
- // In the breach case, we'll only have anchor and
- // breach resolutions.
- contractRes := &ContractResolutions{
- CommitHash: breachInfo.CommitHash,
- BreachResolution: breachInfo.BreachResolution,
- AnchorResolution: breachInfo.AnchorResolution,
- }
-
- // We'll transition to the ContractClosed state and log
- // the set of resolutions such that they can be turned
- // into resolvers later on. We'll also insert the
- // CommitSet of the latest set of commitments.
- err := c.log.LogContractResolutions(contractRes)
- if err != nil {
- log.Errorf("Unable to write resolutions: %v",
- err)
- return
- }
- err = c.log.InsertConfirmedCommitSet(
- &breachInfo.CommitSet,
- )
+ err := c.handleContractBreach(breachInfo)
if err != nil {
- log.Errorf("Unable to write commit set: %v",
- err)
- return
- }
+ log.Errorf("Failed to handle contract breach: "+
+ "%v", err)
- // The channel is finally marked pending closed here as
- // the BreachArbitrator and channel arbitrator have
- // persisted the relevant states.
- closeSummary := &breachInfo.CloseSummary
- err = c.cfg.MarkChannelClosed(
- closeSummary,
- channeldb.ChanStatusRemoteCloseInitiator,
- )
- if err != nil {
- log.Errorf("Unable to mark channel closed: %v",
- err)
return
}
- log.Infof("Breached channel=%v marked pending-closed",
- breachInfo.BreachResolution.FundingOutPoint)
-
- // We'll advance our state machine until it reaches a
- // terminal state.
- _, _, err = c.advanceState(
- uint32(bestHeight), breachCloseTrigger,
- &breachInfo.CommitSet,
- )
- if err != nil {
- log.Errorf("Unable to advance state: %v", err)
- }
-
// A new contract has just been resolved, we'll now check our
// log to see if all contracts have been resolved. If so, then
// we can exit as the contract is fully resolved.
@@ -3131,6 +2986,113 @@ func (c *ChannelArbitrator) channelAttendant(bestHeight int32,
}
}
+// handleBlockbeat processes a newly received blockbeat by advancing the
+// arbitrator's internal state using the received block height.
+func (c *ChannelArbitrator) handleBlockbeat(beat chainio.Blockbeat) error {
+ // Notify we've processed the block.
+ defer c.NotifyBlockProcessed(beat, nil)
+
+ // If the state is StateContractClosed, StateWaitingFullResolution, or
+ // StateFullyResolved, there's no need to read the close event channel
+ // since the arbitrator can only get to this state after processing a
+ // previous close event and launched all its resolvers.
+ if c.state.IsContractClosed() {
+ log.Infof("ChannelArbitrator(%v): skipping reading close "+
+ "events in state=%v", c.cfg.ChanPoint, c.state)
+
+ // Launch all active resolvers when a new blockbeat is
+ // received, even when the contract is closed, we still need
+ // this as the resolvers may transform into new ones. For
+ // already launched resolvers this will be NOOP as they track
+ // their own `launched` states.
+ c.launchResolvers()
+
+ return nil
+ }
+
+ // Perform a non-blocking read on the close events in case the channel
+ // is closed in this blockbeat.
+ c.receiveAndProcessCloseEvent()
+
+ // Try to advance the state if we are in StateDefault.
+ if c.state == StateDefault {
+ // Now that a new block has arrived, we'll attempt to advance
+ // our state forward.
+ _, _, err := c.advanceState(
+ uint32(beat.Height()), chainTrigger, nil,
+ )
+ if err != nil {
+ return fmt.Errorf("unable to advance state: %w", err)
+ }
+ }
+
+ // Launch all active resolvers when a new blockbeat is received.
+ c.launchResolvers()
+
+ return nil
+}
+
+// receiveAndProcessCloseEvent does a non-blocking read on all the channel
+// close event channels. If an event is received, it will be further processed.
+func (c *ChannelArbitrator) receiveAndProcessCloseEvent() {
+ select {
+ // Received a coop close event, we now mark the channel as resolved and
+ // exit.
+ case closeInfo := <-c.cfg.ChainEvents.CooperativeClosure:
+ err := c.handleCoopCloseEvent(closeInfo)
+ if err != nil {
+ log.Errorf("Failed to handle coop close: %v", err)
+ return
+ }
+
+ // We have broadcast our commitment, and it is now confirmed onchain.
+ case closeInfo := <-c.cfg.ChainEvents.LocalUnilateralClosure:
+ if c.state != StateCommitmentBroadcasted {
+ log.Errorf("ChannelArbitrator(%v): unexpected "+
+ "local on-chain channel close", c.cfg.ChanPoint)
+ }
+
+ err := c.handleLocalForceCloseEvent(closeInfo)
+ if err != nil {
+ log.Errorf("Failed to handle local force close: %v",
+ err)
+
+ return
+ }
+
+ // The remote party has broadcast the commitment. We'll examine our
+ // state to determine if we need to act at all.
+ case uniClosure := <-c.cfg.ChainEvents.RemoteUnilateralClosure:
+ err := c.handleRemoteForceCloseEvent(uniClosure)
+ if err != nil {
+ log.Errorf("Failed to handle remote force close: %v",
+ err)
+
+ return
+ }
+
+ // The remote has breached the channel! We now launch the breach
+ // contract resolvers.
+ case breachInfo := <-c.cfg.ChainEvents.ContractBreach:
+ err := c.handleContractBreach(breachInfo)
+ if err != nil {
+ log.Errorf("Failed to handle contract breach: %v", err)
+ return
+ }
+
+ default:
+ log.Infof("ChannelArbitrator(%v) no close event",
+ c.cfg.ChanPoint)
+ }
+}
+
+// Name returns a human-readable string for this subsystem.
+//
+// NOTE: Part of chainio.Consumer interface.
+func (c *ChannelArbitrator) Name() string {
+ return fmt.Sprintf("ChannelArbitrator(%v)", c.cfg.ChanPoint)
+}
+
// checkLegacyBreach returns StateFullyResolved if the channel was closed with
// a breach transaction before the channel arbitrator launched its own breach
// resolver. StateContractClosed is returned if this is a modern breach close
@@ -3416,3 +3378,226 @@ func (c *ChannelArbitrator) abandonForwards(htlcs fn.Set[uint64]) error {
return nil
}
+
+// handleCoopCloseEvent takes a coop close event from ChainEvents, marks the
+// channel as closed and advances the state.
+func (c *ChannelArbitrator) handleCoopCloseEvent(
+ closeInfo *CooperativeCloseInfo) error {
+
+ log.Infof("ChannelArbitrator(%v) marking channel cooperatively closed "+
+ "at height %v", c.cfg.ChanPoint, closeInfo.CloseHeight)
+
+ err := c.cfg.MarkChannelClosed(
+ closeInfo.ChannelCloseSummary,
+ channeldb.ChanStatusCoopBroadcasted,
+ )
+ if err != nil {
+ return fmt.Errorf("unable to mark channel closed: %w", err)
+ }
+
+ // We'll now advance our state machine until it reaches a terminal
+ // state, and the channel is marked resolved.
+ _, _, err = c.advanceState(closeInfo.CloseHeight, coopCloseTrigger, nil)
+ if err != nil {
+ log.Errorf("Unable to advance state: %v", err)
+ }
+
+ return nil
+}
+
+// handleLocalForceCloseEvent takes a local force close event from ChainEvents,
+// saves the contract resolutions to disk, mark the channel as closed and
+// advance the state.
+func (c *ChannelArbitrator) handleLocalForceCloseEvent(
+ closeInfo *LocalUnilateralCloseInfo) error {
+
+ closeTx := closeInfo.CloseTx
+
+ resolutions, err := closeInfo.ContractResolutions.
+ UnwrapOrErr(
+ fmt.Errorf("resolutions not found"),
+ )
+ if err != nil {
+ return fmt.Errorf("unable to get resolutions: %w", err)
+ }
+
+ // We make sure that the htlc resolutions are present
+ // otherwise we would panic dereferencing the pointer.
+ //
+ // TODO(ziggie): Refactor ContractResolutions to use
+ // options.
+ if resolutions.HtlcResolutions == nil {
+ return fmt.Errorf("htlc resolutions is nil")
+ }
+
+ log.Infof("ChannelArbitrator(%v): local force close tx=%v confirmed",
+ c.cfg.ChanPoint, closeTx.TxHash())
+
+ contractRes := &ContractResolutions{
+ CommitHash: closeTx.TxHash(),
+ CommitResolution: resolutions.CommitResolution,
+ HtlcResolutions: *resolutions.HtlcResolutions,
+ AnchorResolution: resolutions.AnchorResolution,
+ }
+
+ // When processing a unilateral close event, we'll transition to the
+ // ContractClosed state. We'll log out the set of resolutions such that
+ // they are available to fetch in that state, we'll also write the
+ // commit set so we can reconstruct our chain actions on restart.
+ err = c.log.LogContractResolutions(contractRes)
+ if err != nil {
+ return fmt.Errorf("unable to write resolutions: %w", err)
+ }
+
+ err = c.log.InsertConfirmedCommitSet(&closeInfo.CommitSet)
+ if err != nil {
+ return fmt.Errorf("unable to write commit set: %w", err)
+ }
+
+ // After the set of resolutions are successfully logged, we can safely
+ // close the channel. After this succeeds we won't be getting chain
+ // events anymore, so we must make sure we can recover on restart after
+ // it is marked closed. If the next state transition fails, we'll start
+ // up in the prior state again, and we won't be longer getting chain
+ // events. In this case we must manually re-trigger the state
+ // transition into StateContractClosed based on the close status of the
+ // channel.
+ err = c.cfg.MarkChannelClosed(
+ closeInfo.ChannelCloseSummary,
+ channeldb.ChanStatusLocalCloseInitiator,
+ )
+ if err != nil {
+ return fmt.Errorf("unable to mark channel closed: %w", err)
+ }
+
+ // We'll now advance our state machine until it reaches a terminal
+ // state.
+ _, _, err = c.advanceState(
+ uint32(closeInfo.SpendingHeight),
+ localCloseTrigger, &closeInfo.CommitSet,
+ )
+ if err != nil {
+ log.Errorf("Unable to advance state: %v", err)
+ }
+
+ return nil
+}
+
+// handleRemoteForceCloseEvent takes a remote force close event from
+// ChainEvents, saves the contract resolutions to disk, mark the channel as
+// closed and advance the state.
+func (c *ChannelArbitrator) handleRemoteForceCloseEvent(
+ closeInfo *RemoteUnilateralCloseInfo) error {
+
+ log.Infof("ChannelArbitrator(%v): remote party has force closed "+
+ "channel at height %v", c.cfg.ChanPoint,
+ closeInfo.SpendingHeight)
+
+ // If we don't have a self output, and there are no active HTLC's, then
+ // we can immediately mark the contract as fully resolved and exit.
+ contractRes := &ContractResolutions{
+ CommitHash: *closeInfo.SpenderTxHash,
+ CommitResolution: closeInfo.CommitResolution,
+ HtlcResolutions: *closeInfo.HtlcResolutions,
+ AnchorResolution: closeInfo.AnchorResolution,
+ }
+
+ // When processing a unilateral close event, we'll transition to the
+ // ContractClosed state. We'll log out the set of resolutions such that
+ // they are available to fetch in that state, we'll also write the
+ // commit set so we can reconstruct our chain actions on restart.
+ err := c.log.LogContractResolutions(contractRes)
+ if err != nil {
+ return fmt.Errorf("unable to write resolutions: %w", err)
+ }
+
+ err = c.log.InsertConfirmedCommitSet(&closeInfo.CommitSet)
+ if err != nil {
+ return fmt.Errorf("unable to write commit set: %w", err)
+ }
+
+ // After the set of resolutions are successfully logged, we can safely
+ // close the channel. After this succeeds we won't be getting chain
+ // events anymore, so we must make sure we can recover on restart after
+ // it is marked closed. If the next state transition fails, we'll start
+ // up in the prior state again, and we won't be longer getting chain
+ // events. In this case we must manually re-trigger the state
+ // transition into StateContractClosed based on the close status of the
+ // channel.
+ closeSummary := &closeInfo.ChannelCloseSummary
+ err = c.cfg.MarkChannelClosed(
+ closeSummary,
+ channeldb.ChanStatusRemoteCloseInitiator,
+ )
+ if err != nil {
+ return fmt.Errorf("unable to mark channel closed: %w", err)
+ }
+
+ // We'll now advance our state machine until it reaches a terminal
+ // state.
+ _, _, err = c.advanceState(
+ uint32(closeInfo.SpendingHeight),
+ remoteCloseTrigger, &closeInfo.CommitSet,
+ )
+ if err != nil {
+ log.Errorf("Unable to advance state: %v", err)
+ }
+
+ return nil
+}
+
+// handleContractBreach takes a breach close event from ChainEvents, saves the
+// contract resolutions to disk, mark the channel as closed and advance the
+// state.
+func (c *ChannelArbitrator) handleContractBreach(
+ breachInfo *BreachCloseInfo) error {
+
+ closeSummary := &breachInfo.CloseSummary
+
+ log.Infof("ChannelArbitrator(%v): remote party has breached channel "+
+ "at height %v!", c.cfg.ChanPoint, closeSummary.CloseHeight)
+
+ // In the breach case, we'll only have anchor and breach resolutions.
+ contractRes := &ContractResolutions{
+ CommitHash: breachInfo.CommitHash,
+ BreachResolution: breachInfo.BreachResolution,
+ AnchorResolution: breachInfo.AnchorResolution,
+ }
+
+ // We'll transition to the ContractClosed state and log the set of
+ // resolutions such that they can be turned into resolvers later on.
+ // We'll also insert the CommitSet of the latest set of commitments.
+ err := c.log.LogContractResolutions(contractRes)
+ if err != nil {
+ return fmt.Errorf("unable to write resolutions: %w", err)
+ }
+
+ err = c.log.InsertConfirmedCommitSet(&breachInfo.CommitSet)
+ if err != nil {
+ return fmt.Errorf("unable to write commit set: %w", err)
+ }
+
+ // The channel is finally marked pending closed here as the
+ // BreachArbitrator and channel arbitrator have persisted the relevant
+ // states.
+ err = c.cfg.MarkChannelClosed(
+ closeSummary, channeldb.ChanStatusRemoteCloseInitiator,
+ )
+ if err != nil {
+ return fmt.Errorf("unable to mark channel closed: %w", err)
+ }
+
+ log.Infof("Breached channel=%v marked pending-closed",
+ breachInfo.BreachResolution.FundingOutPoint)
+
+ // We'll advance our state machine until it reaches a terminal state.
+ _, _, err = c.advanceState(
+ closeSummary.CloseHeight, breachCloseTrigger,
+ &breachInfo.CommitSet,
+ )
+ if err != nil {
+ log.Errorf("Unable to advance state: %v", err)
+ }
+
+ return nil
+}
diff --git a/contractcourt/channel_arbitrator_test.go b/contractcourt/channel_arbitrator_test.go
index 7150e5aea4d..827e10b5c7d 100644
--- a/contractcourt/channel_arbitrator_test.go
+++ b/contractcourt/channel_arbitrator_test.go
@@ -13,6 +13,8 @@ import (
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
+ "github.com/davecgh/go-spew/spew"
+ "github.com/lightningnetwork/lnd/chainio"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/clock"
@@ -227,6 +229,15 @@ func (c *chanArbTestCtx) CleanUp() {
}
}
+// receiveBlockbeat mocks the behavior of a blockbeat being sent by the
+// BlockbeatDispatcher, which essentially mocks the method `ProcessBlock`.
+func (c *chanArbTestCtx) receiveBlockbeat(height int) {
+ go func() {
+ beat := newBeatFromHeight(int32(height))
+ c.chanArb.BlockbeatChan <- beat
+ }()
+}
+
// AssertStateTransitions asserts that the state machine steps through the
// passed states in order.
func (c *chanArbTestCtx) AssertStateTransitions(expectedStates ...ArbitratorState) {
@@ -286,7 +297,8 @@ func (c *chanArbTestCtx) Restart(restartClosure func(*chanArbTestCtx)) (*chanArb
restartClosure(newCtx)
}
- if err := newCtx.chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := newCtx.chanArb.Start(nil, beat); err != nil {
return nil, err
}
@@ -513,7 +525,8 @@ func TestChannelArbitratorCooperativeClose(t *testing.T) {
chanArbCtx, err := createTestChannelArbitrator(t, log)
require.NoError(t, err, "unable to create ChannelArbitrator")
- if err := chanArbCtx.chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArbCtx.chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
t.Cleanup(func() {
@@ -571,7 +584,8 @@ func TestChannelArbitratorRemoteForceClose(t *testing.T) {
require.NoError(t, err, "unable to create ChannelArbitrator")
chanArb := chanArbCtx.chanArb
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
defer chanArb.Stop()
@@ -624,7 +638,8 @@ func TestChannelArbitratorLocalForceClose(t *testing.T) {
require.NoError(t, err, "unable to create ChannelArbitrator")
chanArb := chanArbCtx.chanArb
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
defer chanArb.Stop()
@@ -736,7 +751,8 @@ func TestChannelArbitratorBreachClose(t *testing.T) {
chanArb.cfg.PreimageDB = newMockWitnessBeacon()
chanArb.cfg.Registry = &mockRegistry{}
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
t.Cleanup(func() {
@@ -863,7 +879,8 @@ func TestChannelArbitratorLocalForceClosePendingHtlc(t *testing.T) {
chanArb.cfg.PreimageDB = newMockWitnessBeacon()
chanArb.cfg.Registry = &mockRegistry{}
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
defer chanArb.Stop()
@@ -966,6 +983,7 @@ func TestChannelArbitratorLocalForceClosePendingHtlc(t *testing.T) {
},
},
}
+ closeTxid := closeTx.TxHash()
htlcOp := wire.OutPoint{
Hash: closeTx.TxHash(),
@@ -1037,7 +1055,7 @@ func TestChannelArbitratorLocalForceClosePendingHtlc(t *testing.T) {
}
require.Equal(t, expectedFinalHtlcs, chanArbCtx.finalHtlcs)
- // We'll no re-create the resolver, notice that we use the existing
+ // We'll now re-create the resolver, notice that we use the existing
// arbLog so it carries over the same on-disk state.
chanArbCtxNew, err := chanArbCtx.Restart(nil)
require.NoError(t, err, "unable to create ChannelArbitrator")
@@ -1096,7 +1114,11 @@ func TestChannelArbitratorLocalForceClosePendingHtlc(t *testing.T) {
// Notify resolver that the HTLC output of the commitment has been
// spent.
- oldNotifier.SpendChan <- &chainntnfs.SpendDetail{SpendingTx: closeTx}
+ oldNotifier.SpendChan <- &chainntnfs.SpendDetail{
+ SpendingTx: closeTx,
+ SpentOutPoint: &wire.OutPoint{},
+ SpenderTxHash: &closeTxid,
+ }
// Finally, we should also receive a resolution message instructing the
// switch to cancel back the HTLC.
@@ -1123,8 +1145,12 @@ func TestChannelArbitratorLocalForceClosePendingHtlc(t *testing.T) {
default:
}
- // Notify resolver that the second level transaction is spent.
- oldNotifier.SpendChan <- &chainntnfs.SpendDetail{SpendingTx: closeTx}
+ // Notify resolver that the output of the timeout tx has been spent.
+ oldNotifier.SpendChan <- &chainntnfs.SpendDetail{
+ SpendingTx: closeTx,
+ SpentOutPoint: &wire.OutPoint{},
+ SpenderTxHash: &closeTxid,
+ }
// At this point channel should be marked as resolved.
chanArbCtxNew.AssertStateTransitions(StateFullyResolved)
@@ -1148,7 +1174,8 @@ func TestChannelArbitratorLocalForceCloseRemoteConfirmed(t *testing.T) {
require.NoError(t, err, "unable to create ChannelArbitrator")
chanArb := chanArbCtx.chanArb
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
defer chanArb.Stop()
@@ -1255,7 +1282,8 @@ func TestChannelArbitratorLocalForceDoubleSpend(t *testing.T) {
require.NoError(t, err, "unable to create ChannelArbitrator")
chanArb := chanArbCtx.chanArb
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
defer chanArb.Stop()
@@ -1361,7 +1389,8 @@ func TestChannelArbitratorPersistence(t *testing.T) {
require.NoError(t, err, "unable to create ChannelArbitrator")
chanArb := chanArbCtx.chanArb
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
@@ -1479,7 +1508,8 @@ func TestChannelArbitratorForceCloseBreachedChannel(t *testing.T) {
require.NoError(t, err, "unable to create ChannelArbitrator")
chanArb := chanArbCtx.chanArb
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
@@ -1666,7 +1696,8 @@ func TestChannelArbitratorCommitFailure(t *testing.T) {
}
chanArb := chanArbCtx.chanArb
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
@@ -1750,7 +1781,8 @@ func TestChannelArbitratorEmptyResolutions(t *testing.T) {
chanArb.cfg.ClosingHeight = 100
chanArb.cfg.CloseType = channeldb.RemoteForceClose
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(100)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
@@ -1780,7 +1812,8 @@ func TestChannelArbitratorAlreadyForceClosed(t *testing.T) {
chanArbCtx, err := createTestChannelArbitrator(t, log)
require.NoError(t, err, "unable to create ChannelArbitrator")
chanArb := chanArbCtx.chanArb
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
defer chanArb.Stop()
@@ -1878,9 +1911,10 @@ func TestChannelArbitratorDanglingCommitForceClose(t *testing.T) {
t.Fatalf("unable to create ChannelArbitrator: %v", err)
}
chanArb := chanArbCtx.chanArb
- if err := chanArb.Start(nil); err != nil {
- t.Fatalf("unable to start ChannelArbitrator: %v", err)
- }
+ beat := newBeatFromHeight(0)
+ err = chanArb.Start(nil, beat)
+ require.NoError(t, err)
+
defer chanArb.Stop()
// Now that our channel arb has started, we'll set up
@@ -1924,7 +1958,8 @@ func TestChannelArbitratorDanglingCommitForceClose(t *testing.T) {
// now mine a block (height 5), which is 5 blocks away
// (our grace delta) from the expiry of that HTLC.
case testCase.htlcExpired:
- chanArbCtx.chanArb.blocks <- 5
+ beat := newBeatFromHeight(5)
+ chanArbCtx.chanArb.BlockbeatChan <- beat
// Otherwise, we'll just trigger a regular force close
// request.
@@ -2036,8 +2071,7 @@ func TestChannelArbitratorDanglingCommitForceClose(t *testing.T) {
// so instead, we'll mine another block which'll cause
// it to re-examine its state and realize there're no
// more HTLCs.
- chanArbCtx.chanArb.blocks <- 6
- chanArbCtx.AssertStateTransitions(StateFullyResolved)
+ chanArbCtx.receiveBlockbeat(6)
})
}
}
@@ -2074,7 +2108,8 @@ func TestChannelArbitratorPendingExpiredHTLC(t *testing.T) {
return false
}
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
t.Cleanup(func() {
@@ -2108,13 +2143,15 @@ func TestChannelArbitratorPendingExpiredHTLC(t *testing.T) {
// We will advance the uptime to 10 seconds which should be still within
// the grace period and should not trigger going to chain.
testClock.SetTime(startTime.Add(time.Second * 10))
- chanArbCtx.chanArb.blocks <- 5
+ beat = newBeatFromHeight(5)
+ chanArbCtx.chanArb.BlockbeatChan <- beat
chanArbCtx.AssertState(StateDefault)
// We will advance the uptime to 16 seconds which should trigger going
// to chain.
testClock.SetTime(startTime.Add(time.Second * 16))
- chanArbCtx.chanArb.blocks <- 6
+ beat = newBeatFromHeight(6)
+ chanArbCtx.chanArb.BlockbeatChan <- beat
chanArbCtx.AssertStateTransitions(
StateBroadcastCommit,
StateCommitmentBroadcasted,
@@ -2227,8 +2264,8 @@ func TestRemoteCloseInitiator(t *testing.T) {
"ChannelArbitrator: %v", err)
}
chanArb := chanArbCtx.chanArb
-
- if err := chanArb.Start(nil); err != nil {
+ beat := newBeatFromHeight(0)
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start "+
"ChannelArbitrator: %v", err)
}
@@ -2482,7 +2519,7 @@ func TestSweepAnchors(t *testing.T) {
// Set current block height.
heightHint := uint32(1000)
- chanArbCtx.chanArb.blocks <- int32(heightHint)
+ chanArbCtx.receiveBlockbeat(int(heightHint))
htlcIndexBase := uint64(99)
deadlineDelta := uint32(10)
@@ -2645,7 +2682,7 @@ func TestSweepLocalAnchor(t *testing.T) {
// Set current block height.
heightHint := uint32(1000)
- chanArbCtx.chanArb.blocks <- int32(heightHint)
+ chanArbCtx.receiveBlockbeat(int(heightHint))
htlcIndex := uint64(99)
deadlineDelta := uint32(10)
@@ -2779,7 +2816,9 @@ func TestChannelArbitratorAnchors(t *testing.T) {
},
}
- if err := chanArb.Start(nil); err != nil {
+ heightHint := uint32(1000)
+ beat := newBeatFromHeight(int32(heightHint))
+ if err := chanArb.Start(nil, beat); err != nil {
t.Fatalf("unable to start ChannelArbitrator: %v", err)
}
t.Cleanup(func() {
@@ -2791,27 +2830,28 @@ func TestChannelArbitratorAnchors(t *testing.T) {
}
chanArb.UpdateContractSignals(signals)
- // Set current block height.
- heightHint := uint32(1000)
- chanArbCtx.chanArb.blocks <- int32(heightHint)
-
htlcAmt := lnwire.MilliSatoshi(1_000_000)
// Create testing HTLCs.
- deadlineDelta := uint32(10)
- deadlinePreimageDelta := deadlineDelta + 2
+ spendingHeight := uint32(beat.Height())
+ deadlineDelta := uint32(100)
+
+ deadlinePreimageDelta := deadlineDelta
htlcWithPreimage := channeldb.HTLC{
- HtlcIndex: 99,
- RefundTimeout: heightHint + deadlinePreimageDelta,
+ HtlcIndex: 99,
+ // RefundTimeout is 101.
+ RefundTimeout: spendingHeight + deadlinePreimageDelta,
RHash: rHash,
Incoming: true,
Amt: htlcAmt,
}
+ expectedDeadline := deadlineDelta/2 + spendingHeight
- deadlineHTLCdelta := deadlineDelta + 3
+ deadlineHTLCdelta := deadlineDelta + 40
htlc := channeldb.HTLC{
- HtlcIndex: 100,
- RefundTimeout: heightHint + deadlineHTLCdelta,
+ HtlcIndex: 100,
+ // RefundTimeout is 141.
+ RefundTimeout: spendingHeight + deadlineHTLCdelta,
Amt: htlcAmt,
}
@@ -2896,7 +2936,9 @@ func TestChannelArbitratorAnchors(t *testing.T) {
//nolint:ll
chanArb.cfg.ChainEvents.LocalUnilateralClosure <- &LocalUnilateralCloseInfo{
- SpendDetail: &chainntnfs.SpendDetail{},
+ SpendDetail: &chainntnfs.SpendDetail{
+ SpendingHeight: int32(spendingHeight),
+ },
LocalForceCloseSummary: &lnwallet.LocalForceCloseSummary{
CloseTx: closeTx,
ContractResolutions: fn.Some(lnwallet.ContractResolutions{
@@ -2960,12 +3002,14 @@ func TestChannelArbitratorAnchors(t *testing.T) {
// to htlcWithPreimage's CLTV.
require.Equal(t, 2, len(chanArbCtx.sweeper.deadlines))
require.EqualValues(t,
- heightHint+deadlinePreimageDelta/2,
- chanArbCtx.sweeper.deadlines[0],
+ expectedDeadline,
+ chanArbCtx.sweeper.deadlines[0], "want %d, got %d",
+ expectedDeadline, chanArbCtx.sweeper.deadlines[0],
)
require.EqualValues(t,
- heightHint+deadlinePreimageDelta/2,
- chanArbCtx.sweeper.deadlines[1],
+ expectedDeadline,
+ chanArbCtx.sweeper.deadlines[1], "want %d, got %d",
+ expectedDeadline, chanArbCtx.sweeper.deadlines[1],
)
}
@@ -3067,7 +3111,8 @@ func TestChannelArbitratorStartForceCloseFail(t *testing.T) {
return test.broadcastErr
}
- err = chanArb.Start(nil)
+ beat := newBeatFromHeight(0)
+ err = chanArb.Start(nil, beat)
if !test.expectedStartup {
require.ErrorIs(t, err, test.broadcastErr)
@@ -3115,7 +3160,8 @@ func assertResolverReport(t *testing.T, reports chan *channeldb.ResolverReport,
select {
case report := <-reports:
if !reflect.DeepEqual(report, expected) {
- t.Fatalf("expected: %v, got: %v", expected, report)
+ t.Fatalf("expected: %v, got: %v", spew.Sdump(expected),
+ spew.Sdump(report))
}
case <-time.After(defaultTimeout):
@@ -3146,3 +3192,11 @@ func (m *mockChannel) ForceCloseChan() (*wire.MsgTx, error) {
return &wire.MsgTx{}, nil
}
+
+func newBeatFromHeight(height int32) *chainio.Beat {
+ epoch := chainntnfs.BlockEpoch{
+ Height: height,
+ }
+
+ return chainio.NewBeat(epoch)
+}
diff --git a/contractcourt/commit_sweep_resolver.go b/contractcourt/commit_sweep_resolver.go
index 6019a0dbc6d..55ee08e5d83 100644
--- a/contractcourt/commit_sweep_resolver.go
+++ b/contractcourt/commit_sweep_resolver.go
@@ -39,9 +39,6 @@ type commitSweepResolver struct {
// this HTLC on-chain.
commitResolution lnwallet.CommitOutputResolution
- // resolved reflects if the contract has been fully resolved or not.
- resolved bool
-
// broadcastHeight is the height that the original contract was
// broadcast to the main-chain at. We'll use this value to bound any
// historical queries to the chain for spends/confirmations.
@@ -88,7 +85,7 @@ func newCommitSweepResolver(res lnwallet.CommitOutputResolution,
chanPoint: chanPoint,
}
- r.initLogger(r)
+ r.initLogger(fmt.Sprintf("%T(%v)", r, r.commitResolution.SelfOutPoint))
r.initReport()
return r
@@ -101,36 +98,6 @@ func (c *commitSweepResolver) ResolverKey() []byte {
return key[:]
}
-// waitForHeight registers for block notifications and waits for the provided
-// block height to be reached.
-func waitForHeight(waitHeight uint32, notifier chainntnfs.ChainNotifier,
- quit <-chan struct{}) error {
-
- // Register for block epochs. After registration, the current height
- // will be sent on the channel immediately.
- blockEpochs, err := notifier.RegisterBlockEpochNtfn(nil)
- if err != nil {
- return err
- }
- defer blockEpochs.Cancel()
-
- for {
- select {
- case newBlock, ok := <-blockEpochs.Epochs:
- if !ok {
- return errResolverShuttingDown
- }
- height := newBlock.Height
- if height >= int32(waitHeight) {
- return nil
- }
-
- case <-quit:
- return errResolverShuttingDown
- }
- }
-}
-
// waitForSpend waits for the given outpoint to be spent, and returns the
// details of the spending tx.
func waitForSpend(op *wire.OutPoint, pkScript []byte, heightHint uint32,
@@ -195,203 +162,17 @@ func (c *commitSweepResolver) getCommitTxConfHeight() (uint32, error) {
// returned.
//
// NOTE: This function MUST be run as a goroutine.
+
+// TODO(yy): fix the funlen in the next PR.
//
//nolint:funlen
-func (c *commitSweepResolver) Resolve(_ bool) (ContractResolver, error) {
+func (c *commitSweepResolver) Resolve() (ContractResolver, error) {
// If we're already resolved, then we can exit early.
- if c.resolved {
+ if c.IsResolved() {
+ c.log.Errorf("already resolved")
return nil, nil
}
- confHeight, err := c.getCommitTxConfHeight()
- if err != nil {
- return nil, err
- }
-
- // Wait up until the CSV expires, unless we also have a CLTV that
- // expires after.
- unlockHeight := confHeight + c.commitResolution.MaturityDelay
- if c.hasCLTV() {
- unlockHeight = uint32(math.Max(
- float64(unlockHeight), float64(c.leaseExpiry),
- ))
- }
-
- c.log.Debugf("commit conf_height=%v, unlock_height=%v",
- confHeight, unlockHeight)
-
- // Update report now that we learned the confirmation height.
- c.reportLock.Lock()
- c.currentReport.MaturityHeight = unlockHeight
- c.reportLock.Unlock()
-
- // If there is a csv/cltv lock, we'll wait for that.
- if c.commitResolution.MaturityDelay > 0 || c.hasCLTV() {
- // Determine what height we should wait until for the locks to
- // expire.
- var waitHeight uint32
- switch {
- // If we have both a csv and cltv lock, we'll need to look at
- // both and see which expires later.
- case c.commitResolution.MaturityDelay > 0 && c.hasCLTV():
- c.log.Debugf("waiting for CSV and CLTV lock to expire "+
- "at height %v", unlockHeight)
- // If the CSV expires after the CLTV, or there is no
- // CLTV, then we can broadcast a sweep a block before.
- // Otherwise, we need to broadcast at our expected
- // unlock height.
- waitHeight = uint32(math.Max(
- float64(unlockHeight-1), float64(c.leaseExpiry),
- ))
-
- // If we only have a csv lock, wait for the height before the
- // lock expires as the spend path should be unlocked by then.
- case c.commitResolution.MaturityDelay > 0:
- c.log.Debugf("waiting for CSV lock to expire at "+
- "height %v", unlockHeight)
- waitHeight = unlockHeight - 1
- }
-
- err := waitForHeight(waitHeight, c.Notifier, c.quit)
- if err != nil {
- return nil, err
- }
- }
-
- var (
- isLocalCommitTx bool
-
- signDesc = c.commitResolution.SelfOutputSignDesc
- )
-
- switch {
- // For taproot channels, we'll know if this is the local commit based
- // on the timelock value. For remote commitment transactions, the
- // witness script has a timelock of 1.
- case c.chanType.IsTaproot():
- delayKey := c.localChanCfg.DelayBasePoint.PubKey
- nonDelayKey := c.localChanCfg.PaymentBasePoint.PubKey
-
- signKey := c.commitResolution.SelfOutputSignDesc.KeyDesc.PubKey
-
- // If the key in the script is neither of these, we shouldn't
- // proceed. This should be impossible.
- if !signKey.IsEqual(delayKey) && !signKey.IsEqual(nonDelayKey) {
- return nil, fmt.Errorf("unknown sign key %v", signKey)
- }
-
- // The commitment transaction is ours iff the signing key is
- // the delay key.
- isLocalCommitTx = signKey.IsEqual(delayKey)
-
- // The output is on our local commitment if the script starts with
- // OP_IF for the revocation clause. On the remote commitment it will
- // either be a regular P2WKH or a simple sig spend with a CSV delay.
- default:
- isLocalCommitTx = signDesc.WitnessScript[0] == txscript.OP_IF
- }
- isDelayedOutput := c.commitResolution.MaturityDelay != 0
-
- c.log.Debugf("isDelayedOutput=%v, isLocalCommitTx=%v", isDelayedOutput,
- isLocalCommitTx)
-
- // There're three types of commitments, those that have tweaks for the
- // remote key (us in this case), those that don't, and a third where
- // there is no tweak and the output is delayed. On the local commitment
- // our output will always be delayed. We'll rely on the presence of the
- // commitment tweak to discern which type of commitment this is.
- var witnessType input.WitnessType
- switch {
- // The local delayed output for a taproot channel.
- case isLocalCommitTx && c.chanType.IsTaproot():
- witnessType = input.TaprootLocalCommitSpend
-
- // The CSV 1 delayed output for a taproot channel.
- case !isLocalCommitTx && c.chanType.IsTaproot():
- witnessType = input.TaprootRemoteCommitSpend
-
- // Delayed output to us on our local commitment for a channel lease in
- // which we are the initiator.
- case isLocalCommitTx && c.hasCLTV():
- witnessType = input.LeaseCommitmentTimeLock
-
- // Delayed output to us on our local commitment.
- case isLocalCommitTx:
- witnessType = input.CommitmentTimeLock
-
- // A confirmed output to us on the remote commitment for a channel lease
- // in which we are the initiator.
- case isDelayedOutput && c.hasCLTV():
- witnessType = input.LeaseCommitmentToRemoteConfirmed
-
- // A confirmed output to us on the remote commitment.
- case isDelayedOutput:
- witnessType = input.CommitmentToRemoteConfirmed
-
- // A non-delayed output on the remote commitment where the key is
- // tweakless.
- case c.commitResolution.SelfOutputSignDesc.SingleTweak == nil:
- witnessType = input.CommitSpendNoDelayTweakless
-
- // A non-delayed output on the remote commitment where the key is
- // tweaked.
- default:
- witnessType = input.CommitmentNoDelay
- }
-
- c.log.Infof("Sweeping with witness type: %v", witnessType)
-
- // We'll craft an input with all the information required for the
- // sweeper to create a fully valid sweeping transaction to recover
- // these coins.
- var inp *input.BaseInput
- if c.hasCLTV() {
- inp = input.NewCsvInputWithCltv(
- &c.commitResolution.SelfOutPoint, witnessType,
- &c.commitResolution.SelfOutputSignDesc,
- c.broadcastHeight, c.commitResolution.MaturityDelay,
- c.leaseExpiry,
- input.WithResolutionBlob(
- c.commitResolution.ResolutionBlob,
- ),
- )
- } else {
- inp = input.NewCsvInput(
- &c.commitResolution.SelfOutPoint, witnessType,
- &c.commitResolution.SelfOutputSignDesc,
- c.broadcastHeight, c.commitResolution.MaturityDelay,
- input.WithResolutionBlob(
- c.commitResolution.ResolutionBlob,
- ),
- )
- }
-
- // TODO(roasbeef): instead of ading ctrl block to the sign desc, make
- // new input type, have sweeper set it?
-
- // Calculate the budget for the sweeping this input.
- budget := calculateBudget(
- btcutil.Amount(inp.SignDesc().Output.Value),
- c.Budget.ToLocalRatio, c.Budget.ToLocal,
- )
- c.log.Infof("Sweeping commit output using budget=%v", budget)
-
- // With our input constructed, we'll now offer it to the sweeper.
- resultChan, err := c.Sweeper.SweepInput(
- inp, sweep.Params{
- Budget: budget,
-
- // Specify a nil deadline here as there's no time
- // pressure.
- DeadlineHeight: fn.None[int32](),
- },
- )
- if err != nil {
- c.log.Errorf("unable to sweep input: %v", err)
-
- return nil, err
- }
-
var sweepTxID chainhash.Hash
// Sweeper is going to join this input with other inputs if possible
@@ -400,7 +181,7 @@ func (c *commitSweepResolver) Resolve(_ bool) (ContractResolver, error) {
// happen.
outcome := channeldb.ResolverOutcomeClaimed
select {
- case sweepResult := <-resultChan:
+ case sweepResult := <-c.sweepResultChan:
switch sweepResult.Err {
// If the remote party was able to sweep this output it's
// likely what we sent was actually a revoked commitment.
@@ -440,7 +221,7 @@ func (c *commitSweepResolver) Resolve(_ bool) (ContractResolver, error) {
report := c.currentReport.resolverReport(
&sweepTxID, channeldb.ResolverTypeCommit, outcome,
)
- c.resolved = true
+ c.markResolved()
// Checkpoint the resolver with a closure that will write the outcome
// of the resolver and its sweep transaction to disk.
@@ -452,17 +233,11 @@ func (c *commitSweepResolver) Resolve(_ bool) (ContractResolver, error) {
//
// NOTE: Part of the ContractResolver interface.
func (c *commitSweepResolver) Stop() {
+ c.log.Debugf("stopping...")
+ defer c.log.Debugf("stopped")
close(c.quit)
}
-// IsResolved returns true if the stored state in the resolve is fully
-// resolved. In this case the target output can be forgotten.
-//
-// NOTE: Part of the ContractResolver interface.
-func (c *commitSweepResolver) IsResolved() bool {
- return c.resolved
-}
-
// SupplementState allows the user of a ContractResolver to supplement it with
// state required for the proper resolution of a contract.
//
@@ -491,7 +266,7 @@ func (c *commitSweepResolver) Encode(w io.Writer) error {
return err
}
- if err := binary.Write(w, endian, c.resolved); err != nil {
+ if err := binary.Write(w, endian, c.IsResolved()); err != nil {
return err
}
if err := binary.Write(w, endian, c.broadcastHeight); err != nil {
@@ -526,9 +301,14 @@ func newCommitSweepResolverFromReader(r io.Reader, resCfg ResolverConfig) (
return nil, err
}
- if err := binary.Read(r, endian, &c.resolved); err != nil {
+ var resolved bool
+ if err := binary.Read(r, endian, &resolved); err != nil {
return nil, err
}
+ if resolved {
+ c.markResolved()
+ }
+
if err := binary.Read(r, endian, &c.broadcastHeight); err != nil {
return nil, err
}
@@ -545,7 +325,7 @@ func newCommitSweepResolverFromReader(r io.Reader, resCfg ResolverConfig) (
// removed this, but keep in mind that this data may still be present in
// the database.
- c.initLogger(c)
+ c.initLogger(fmt.Sprintf("%T(%v)", c, c.commitResolution.SelfOutPoint))
c.initReport()
return c, nil
@@ -585,3 +365,181 @@ func (c *commitSweepResolver) initReport() {
// A compile time assertion to ensure commitSweepResolver meets the
// ContractResolver interface.
var _ reportingContractResolver = (*commitSweepResolver)(nil)
+
+// Launch constructs a commit input and offers it to the sweeper.
+func (c *commitSweepResolver) Launch() error {
+ if c.isLaunched() {
+ c.log.Tracef("already launched")
+ return nil
+ }
+
+ c.log.Debugf("launching resolver...")
+ c.markLaunched()
+
+ // If we're already resolved, then we can exit early.
+ if c.IsResolved() {
+ c.log.Errorf("already resolved")
+ return nil
+ }
+
+ confHeight, err := c.getCommitTxConfHeight()
+ if err != nil {
+ return err
+ }
+
+ // Wait up until the CSV expires, unless we also have a CLTV that
+ // expires after.
+ unlockHeight := confHeight + c.commitResolution.MaturityDelay
+ if c.hasCLTV() {
+ unlockHeight = uint32(math.Max(
+ float64(unlockHeight), float64(c.leaseExpiry),
+ ))
+ }
+
+ // Update report now that we learned the confirmation height.
+ c.reportLock.Lock()
+ c.currentReport.MaturityHeight = unlockHeight
+ c.reportLock.Unlock()
+
+ // Derive the witness type for this input.
+ witnessType, err := c.decideWitnessType()
+ if err != nil {
+ return err
+ }
+
+ // We'll craft an input with all the information required for the
+ // sweeper to create a fully valid sweeping transaction to recover
+ // these coins.
+ var inp *input.BaseInput
+ if c.hasCLTV() {
+ inp = input.NewCsvInputWithCltv(
+ &c.commitResolution.SelfOutPoint, witnessType,
+ &c.commitResolution.SelfOutputSignDesc,
+ c.broadcastHeight, c.commitResolution.MaturityDelay,
+ c.leaseExpiry,
+ )
+ } else {
+ inp = input.NewCsvInput(
+ &c.commitResolution.SelfOutPoint, witnessType,
+ &c.commitResolution.SelfOutputSignDesc,
+ c.broadcastHeight, c.commitResolution.MaturityDelay,
+ )
+ }
+
+ // TODO(roasbeef): instead of ading ctrl block to the sign desc, make
+ // new input type, have sweeper set it?
+
+ // Calculate the budget for the sweeping this input.
+ budget := calculateBudget(
+ btcutil.Amount(inp.SignDesc().Output.Value),
+ c.Budget.ToLocalRatio, c.Budget.ToLocal,
+ )
+ c.log.Infof("sweeping commit output %v using budget=%v", witnessType,
+ budget)
+
+ // With our input constructed, we'll now offer it to the sweeper.
+ resultChan, err := c.Sweeper.SweepInput(
+ inp, sweep.Params{
+ Budget: budget,
+
+ // Specify a nil deadline here as there's no time
+ // pressure.
+ DeadlineHeight: fn.None[int32](),
+ },
+ )
+ if err != nil {
+ c.log.Errorf("unable to sweep input: %v", err)
+
+ return err
+ }
+
+ c.sweepResultChan = resultChan
+
+ return nil
+}
+
+// decideWitnessType returns the witness type for the input.
+func (c *commitSweepResolver) decideWitnessType() (input.WitnessType, error) {
+ var (
+ isLocalCommitTx bool
+ signDesc = c.commitResolution.SelfOutputSignDesc
+ )
+
+ switch {
+ // For taproot channels, we'll know if this is the local commit based
+ // on the timelock value. For remote commitment transactions, the
+ // witness script has a timelock of 1.
+ case c.chanType.IsTaproot():
+ delayKey := c.localChanCfg.DelayBasePoint.PubKey
+ nonDelayKey := c.localChanCfg.PaymentBasePoint.PubKey
+
+ signKey := c.commitResolution.SelfOutputSignDesc.KeyDesc.PubKey
+
+ // If the key in the script is neither of these, we shouldn't
+ // proceed. This should be impossible.
+ if !signKey.IsEqual(delayKey) && !signKey.IsEqual(nonDelayKey) {
+ return nil, fmt.Errorf("unknown sign key %v", signKey)
+ }
+
+ // The commitment transaction is ours iff the signing key is
+ // the delay key.
+ isLocalCommitTx = signKey.IsEqual(delayKey)
+
+ // The output is on our local commitment if the script starts with
+ // OP_IF for the revocation clause. On the remote commitment it will
+ // either be a regular P2WKH or a simple sig spend with a CSV delay.
+ default:
+ isLocalCommitTx = signDesc.WitnessScript[0] == txscript.OP_IF
+ }
+
+ isDelayedOutput := c.commitResolution.MaturityDelay != 0
+
+ c.log.Debugf("isDelayedOutput=%v, isLocalCommitTx=%v", isDelayedOutput,
+ isLocalCommitTx)
+
+ // There're three types of commitments, those that have tweaks for the
+ // remote key (us in this case), those that don't, and a third where
+ // there is no tweak and the output is delayed. On the local commitment
+ // our output will always be delayed. We'll rely on the presence of the
+ // commitment tweak to discern which type of commitment this is.
+ var witnessType input.WitnessType
+ switch {
+ // The local delayed output for a taproot channel.
+ case isLocalCommitTx && c.chanType.IsTaproot():
+ witnessType = input.TaprootLocalCommitSpend
+
+ // The CSV 1 delayed output for a taproot channel.
+ case !isLocalCommitTx && c.chanType.IsTaproot():
+ witnessType = input.TaprootRemoteCommitSpend
+
+ // Delayed output to us on our local commitment for a channel lease in
+ // which we are the initiator.
+ case isLocalCommitTx && c.hasCLTV():
+ witnessType = input.LeaseCommitmentTimeLock
+
+ // Delayed output to us on our local commitment.
+ case isLocalCommitTx:
+ witnessType = input.CommitmentTimeLock
+
+ // A confirmed output to us on the remote commitment for a channel lease
+ // in which we are the initiator.
+ case isDelayedOutput && c.hasCLTV():
+ witnessType = input.LeaseCommitmentToRemoteConfirmed
+
+ // A confirmed output to us on the remote commitment.
+ case isDelayedOutput:
+ witnessType = input.CommitmentToRemoteConfirmed
+
+ // A non-delayed output on the remote commitment where the key is
+ // tweakless.
+ case c.commitResolution.SelfOutputSignDesc.SingleTweak == nil:
+ witnessType = input.CommitSpendNoDelayTweakless
+
+ // A non-delayed output on the remote commitment where the key is
+ // tweaked.
+ default:
+ witnessType = input.CommitmentNoDelay
+ }
+
+ return witnessType, nil
+}
diff --git a/contractcourt/commit_sweep_resolver_test.go b/contractcourt/commit_sweep_resolver_test.go
index 077fb8f82c2..6855fddcd32 100644
--- a/contractcourt/commit_sweep_resolver_test.go
+++ b/contractcourt/commit_sweep_resolver_test.go
@@ -15,6 +15,7 @@ import (
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/sweep"
+ "github.com/stretchr/testify/require"
)
type commitSweepResolverTestContext struct {
@@ -82,7 +83,10 @@ func (i *commitSweepResolverTestContext) resolve() {
// Start resolver.
i.resolverResultChan = make(chan resolveResult, 1)
go func() {
- nextResolver, err := i.resolver.Resolve(false)
+ err := i.resolver.Launch()
+ require.NoError(i.t, err)
+
+ nextResolver, err := i.resolver.Resolve()
i.resolverResultChan <- resolveResult{
nextResolver: nextResolver,
err: err,
@@ -90,12 +94,6 @@ func (i *commitSweepResolverTestContext) resolve() {
}()
}
-func (i *commitSweepResolverTestContext) notifyEpoch(height int32) {
- i.notifier.EpochChan <- &chainntnfs.BlockEpoch{
- Height: height,
- }
-}
-
func (i *commitSweepResolverTestContext) waitForResult() {
i.t.Helper()
@@ -292,22 +290,10 @@ func testCommitSweepResolverDelay(t *testing.T, sweepErr error) {
t.Fatal("report maturity height incorrect")
}
- // Notify initial block height. The csv lock is still in effect, so we
- // don't expect any sweep to happen yet.
- ctx.notifyEpoch(testInitialBlockHeight)
-
- select {
- case <-ctx.sweeper.sweptInputs:
- t.Fatal("no sweep expected")
- case <-time.After(sweepProcessInterval):
- }
-
- // A new block arrives. The commit tx confirmed at height -1 and the csv
- // is 3, so a spend will be valid in the first block after height +1.
- ctx.notifyEpoch(testInitialBlockHeight + 1)
-
- <-ctx.sweeper.sweptInputs
-
+ // Notify initial block height. Although the csv lock is still in
+ // effect, we expect the input being sent to the sweeper before the csv
+ // lock expires.
+ //
// Set the resolution report outcome based on whether our sweep
// succeeded.
outcome := channeldb.ResolverOutcomeClaimed
diff --git a/contractcourt/contract_resolver.go b/contractcourt/contract_resolver.go
index f5a88f24e68..d11bd2f597a 100644
--- a/contractcourt/contract_resolver.go
+++ b/contractcourt/contract_resolver.go
@@ -5,11 +5,13 @@ import (
"errors"
"fmt"
"io"
+ "sync/atomic"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btclog/v2"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/lightningnetwork/lnd/sweep"
)
var (
@@ -35,6 +37,17 @@ type ContractResolver interface {
// resides within.
ResolverKey() []byte
+ // Launch starts the resolver by constructing an input and offering it
+ // to the sweeper. Once offered, it's expected to monitor the sweeping
+ // result in a goroutine invoked by calling Resolve.
+ //
+ // NOTE: We can call `Resolve` inside a goroutine at the end of this
+ // method to avoid calling it in the ChannelArbitrator. However, there
+ // are some DB-related operations such as SwapContract/ResolveContract
+ // which need to be done inside the resolvers instead, which needs a
+ // deeper refactoring.
+ Launch() error
+
// Resolve instructs the contract resolver to resolve the output
// on-chain. Once the output has been *fully* resolved, the function
// should return immediately with a nil ContractResolver value for the
@@ -42,7 +55,7 @@ type ContractResolver interface {
// resolution, then another resolve is returned.
//
// NOTE: This function MUST be run as a goroutine.
- Resolve(immediate bool) (ContractResolver, error)
+ Resolve() (ContractResolver, error)
// SupplementState allows the user of a ContractResolver to supplement
// it with state required for the proper resolution of a contract.
@@ -109,6 +122,21 @@ type contractResolverKit struct {
log btclog.Logger
quit chan struct{}
+
+ // sweepResultChan is the result chan returned from calling
+ // `SweepInput`. It should be mounted to the specific resolver once the
+ // input has been offered to the sweeper.
+ sweepResultChan chan sweep.Result
+
+ // launched specifies whether the resolver has been launched. Calling
+ // `Launch` will be a no-op if this is true. This value is not saved to
+ // db, as it's fine to relaunch a resolver after a restart. It's only
+ // used to avoid resending requests to the sweeper when a new blockbeat
+ // is received.
+ launched atomic.Bool
+
+ // resolved reflects if the contract has been fully resolved or not.
+ resolved atomic.Bool
}
// newContractResolverKit instantiates the mix-in struct.
@@ -120,11 +148,36 @@ func newContractResolverKit(cfg ResolverConfig) *contractResolverKit {
}
// initLogger initializes the resolver-specific logger.
-func (r *contractResolverKit) initLogger(resolver ContractResolver) {
- logPrefix := fmt.Sprintf("%T(%v):", resolver, r.ChanPoint)
+func (r *contractResolverKit) initLogger(prefix string) {
+ logPrefix := fmt.Sprintf("ChannelArbitrator(%v): %s:", r.ChanPoint,
+ prefix)
+
r.log = log.WithPrefix(logPrefix)
}
+// IsResolved returns true if the stored state in the resolve is fully
+// resolved. In this case the target output can be forgotten.
+//
+// NOTE: Part of the ContractResolver interface.
+func (r *contractResolverKit) IsResolved() bool {
+ return r.resolved.Load()
+}
+
+// markResolved marks the resolver as resolved.
+func (r *contractResolverKit) markResolved() {
+ r.resolved.Store(true)
+}
+
+// isLaunched returns true if the resolver has been launched.
+func (r *contractResolverKit) isLaunched() bool {
+ return r.launched.Load()
+}
+
+// markLaunched marks the resolver as launched.
+func (r *contractResolverKit) markLaunched() {
+ r.launched.Store(true)
+}
+
var (
// errResolverShuttingDown is returned when the resolver stops
// progressing because it received the quit signal.
diff --git a/contractcourt/htlc_incoming_contest_resolver.go b/contractcourt/htlc_incoming_contest_resolver.go
index ee526dfff3d..95d08c417f8 100644
--- a/contractcourt/htlc_incoming_contest_resolver.go
+++ b/contractcourt/htlc_incoming_contest_resolver.go
@@ -78,6 +78,37 @@ func (h *htlcIncomingContestResolver) processFinalHtlcFail() error {
return nil
}
+// Launch will call the inner resolver's launch method if the preimage can be
+// found, otherwise it's a no-op.
+func (h *htlcIncomingContestResolver) Launch() error {
+ // NOTE: we don't mark this resolver as launched as the inner resolver
+ // will set it when it's launched.
+ if h.isLaunched() {
+ h.log.Tracef("already launched")
+ return nil
+ }
+
+ h.log.Debugf("launching contest resolver...")
+
+ // Query the preimage and apply it if we already know it.
+ applied, err := h.findAndapplyPreimage()
+ if err != nil {
+ return err
+ }
+
+ // No preimage found, leave it to be handled by the resolver.
+ if !applied {
+ return nil
+ }
+
+ h.log.Debugf("found preimage for htlc=%x, transforming into success "+
+ "resolver and launching it", h.htlc.RHash)
+
+ // Once we've applied the preimage, we'll launch the inner resolver to
+ // attempt to claim the HTLC.
+ return h.htlcSuccessResolver.Launch()
+}
+
// Resolve attempts to resolve this contract. As we don't yet know of the
// preimage for the contract, we'll wait for one of two things to happen:
//
@@ -90,12 +121,11 @@ func (h *htlcIncomingContestResolver) processFinalHtlcFail() error {
// as we have no remaining actions left at our disposal.
//
// NOTE: Part of the ContractResolver interface.
-func (h *htlcIncomingContestResolver) Resolve(
- _ bool) (ContractResolver, error) {
-
+func (h *htlcIncomingContestResolver) Resolve() (ContractResolver, error) {
// If we're already full resolved, then we don't have anything further
// to do.
- if h.resolved {
+ if h.IsResolved() {
+ h.log.Errorf("already resolved")
return nil, nil
}
@@ -103,15 +133,14 @@ func (h *htlcIncomingContestResolver) Resolve(
// now.
payload, nextHopOnionBlob, err := h.decodePayload()
if err != nil {
- log.Debugf("ChannelArbitrator(%v): cannot decode payload of "+
- "htlc %v", h.ChanPoint, h.HtlcPoint())
+ h.log.Debugf("cannot decode payload of htlc %v", h.HtlcPoint())
// If we've locked in an htlc with an invalid payload on our
// commitment tx, we don't need to resolve it. The other party
// will time it out and get their funds back. This situation
// can present itself when we crash before processRemoteAdds in
// the link has ran.
- h.resolved = true
+ h.markResolved()
if err := h.processFinalHtlcFail(); err != nil {
return nil, err
@@ -164,7 +193,7 @@ func (h *htlcIncomingContestResolver) Resolve(
log.Infof("%T(%v): HTLC has timed out (expiry=%v, height=%v), "+
"abandoning", h, h.htlcResolution.ClaimOutpoint,
h.htlcExpiry, currentHeight)
- h.resolved = true
+ h.markResolved()
if err := h.processFinalHtlcFail(); err != nil {
return nil, err
@@ -179,65 +208,6 @@ func (h *htlcIncomingContestResolver) Resolve(
return nil, h.Checkpoint(h, report)
}
- // applyPreimage is a helper function that will populate our internal
- // resolver with the preimage we learn of. This should be called once
- // the preimage is revealed so the inner resolver can properly complete
- // its duties. The error return value indicates whether the preimage
- // was properly applied.
- applyPreimage := func(preimage lntypes.Preimage) error {
- // Sanity check to see if this preimage matches our htlc. At
- // this point it should never happen that it does not match.
- if !preimage.Matches(h.htlc.RHash) {
- return errors.New("preimage does not match hash")
- }
-
- // Update htlcResolution with the matching preimage.
- h.htlcResolution.Preimage = preimage
-
- log.Infof("%T(%v): applied preimage=%v", h,
- h.htlcResolution.ClaimOutpoint, preimage)
-
- isSecondLevel := h.htlcResolution.SignedSuccessTx != nil
-
- // If we didn't have to go to the second level to claim (this
- // is the remote commitment transaction), then we don't need to
- // modify our canned witness.
- if !isSecondLevel {
- return nil
- }
-
- isTaproot := txscript.IsPayToTaproot(
- h.htlcResolution.SignedSuccessTx.TxOut[0].PkScript,
- )
-
- // If this is our commitment transaction, then we'll need to
- // populate the witness for the second-level HTLC transaction.
- switch {
- // For taproot channels, the witness for sweeping with success
- // looks like:
- // -
- //
- //
- // So we'll insert it at the 3rd index of the witness.
- case isTaproot:
- //nolint:ll
- h.htlcResolution.SignedSuccessTx.TxIn[0].Witness[2] = preimage[:]
-
- // Within the witness for the success transaction, the
- // preimage is the 4th element as it looks like:
- //
- // * <0>
- //
- // We'll populate it within the witness, as since this
- // was a "contest" resolver, we didn't yet know of the
- // preimage.
- case !isTaproot:
- h.htlcResolution.SignedSuccessTx.TxIn[0].Witness[3] = preimage[:]
- }
-
- return nil
- }
-
// Define a closure to process htlc resolutions either directly or
// triggered by future notifications.
processHtlcResolution := func(e invoices.HtlcResolution) (
@@ -249,7 +219,7 @@ func (h *htlcIncomingContestResolver) Resolve(
// If the htlc resolution was a settle, apply the
// preimage and return a success resolver.
case *invoices.HtlcSettleResolution:
- err := applyPreimage(resolution.Preimage)
+ err := h.applyPreimage(resolution.Preimage)
if err != nil {
return nil, err
}
@@ -264,7 +234,7 @@ func (h *htlcIncomingContestResolver) Resolve(
h.htlcResolution.ClaimOutpoint,
h.htlcExpiry, currentHeight)
- h.resolved = true
+ h.markResolved()
if err := h.processFinalHtlcFail(); err != nil {
return nil, err
@@ -315,6 +285,9 @@ func (h *htlcIncomingContestResolver) Resolve(
return nil, err
}
+ h.log.Debugf("received resolution from registry: %v",
+ resolution)
+
defer func() {
h.Registry.HodlUnsubscribeAll(hodlQueue.ChanIn())
@@ -372,7 +345,9 @@ func (h *htlcIncomingContestResolver) Resolve(
// However, we don't know how to ourselves, so we'll
// return our inner resolver which has the knowledge to
// do so.
- if err := applyPreimage(preimage); err != nil {
+ h.log.Debugf("Found preimage for htlc=%x", h.htlc.RHash)
+
+ if err := h.applyPreimage(preimage); err != nil {
return nil, err
}
@@ -391,7 +366,10 @@ func (h *htlcIncomingContestResolver) Resolve(
continue
}
- if err := applyPreimage(preimage); err != nil {
+ h.log.Debugf("Received preimage for htlc=%x",
+ h.htlc.RHash)
+
+ if err := h.applyPreimage(preimage); err != nil {
return nil, err
}
@@ -418,7 +396,8 @@ func (h *htlcIncomingContestResolver) Resolve(
"(expiry=%v, height=%v), abandoning", h,
h.htlcResolution.ClaimOutpoint,
h.htlcExpiry, currentHeight)
- h.resolved = true
+
+ h.markResolved()
if err := h.processFinalHtlcFail(); err != nil {
return nil, err
@@ -438,6 +417,76 @@ func (h *htlcIncomingContestResolver) Resolve(
}
}
+// applyPreimage is a helper function that will populate our internal resolver
+// with the preimage we learn of. This should be called once the preimage is
+// revealed so the inner resolver can properly complete its duties. The error
+// return value indicates whether the preimage was properly applied.
+func (h *htlcIncomingContestResolver) applyPreimage(
+ preimage lntypes.Preimage) error {
+
+ // Sanity check to see if this preimage matches our htlc. At this point
+ // it should never happen that it does not match.
+ if !preimage.Matches(h.htlc.RHash) {
+ return errors.New("preimage does not match hash")
+ }
+
+ // We may already have the preimage since both the `Launch` and
+ // `Resolve` methods will look for it.
+ if h.htlcResolution.Preimage != lntypes.ZeroHash {
+ h.log.Debugf("already applied preimage for htlc=%x",
+ h.htlc.RHash)
+
+ return nil
+ }
+
+ // Update htlcResolution with the matching preimage.
+ h.htlcResolution.Preimage = preimage
+
+ log.Infof("%T(%v): applied preimage=%v", h,
+ h.htlcResolution.ClaimOutpoint, preimage)
+
+ isSecondLevel := h.htlcResolution.SignedSuccessTx != nil
+
+ // If we didn't have to go to the second level to claim (this
+ // is the remote commitment transaction), then we don't need to
+ // modify our canned witness.
+ if !isSecondLevel {
+ return nil
+ }
+
+ isTaproot := txscript.IsPayToTaproot(
+ h.htlcResolution.SignedSuccessTx.TxOut[0].PkScript,
+ )
+
+ // If this is our commitment transaction, then we'll need to
+ // populate the witness for the second-level HTLC transaction.
+ switch {
+ // For taproot channels, the witness for sweeping with success
+ // looks like:
+ // -
+ //
+ //
+ // So we'll insert it at the 3rd index of the witness.
+ case isTaproot:
+ //nolint:ll
+ h.htlcResolution.SignedSuccessTx.TxIn[0].Witness[2] = preimage[:]
+
+ // Within the witness for the success transaction, the
+ // preimage is the 4th element as it looks like:
+ //
+ // * <0>
+ //
+ // We'll populate it within the witness, as since this
+ // was a "contest" resolver, we didn't yet know of the
+ // preimage.
+ case !isTaproot:
+ //nolint:ll
+ h.htlcResolution.SignedSuccessTx.TxIn[0].Witness[3] = preimage[:]
+ }
+
+ return nil
+}
+
// report returns a report on the resolution state of the contract.
func (h *htlcIncomingContestResolver) report() *ContractReport {
// No locking needed as these values are read-only.
@@ -464,17 +513,11 @@ func (h *htlcIncomingContestResolver) report() *ContractReport {
//
// NOTE: Part of the ContractResolver interface.
func (h *htlcIncomingContestResolver) Stop() {
+ h.log.Debugf("stopping...")
+ defer h.log.Debugf("stopped")
close(h.quit)
}
-// IsResolved returns true if the stored state in the resolve is fully
-// resolved. In this case the target output can be forgotten.
-//
-// NOTE: Part of the ContractResolver interface.
-func (h *htlcIncomingContestResolver) IsResolved() bool {
- return h.resolved
-}
-
// Encode writes an encoded version of the ContractResolver into the passed
// Writer.
//
@@ -563,3 +606,82 @@ func (h *htlcIncomingContestResolver) decodePayload() (*hop.Payload,
// A compile time assertion to ensure htlcIncomingContestResolver meets the
// ContractResolver interface.
var _ htlcContractResolver = (*htlcIncomingContestResolver)(nil)
+
+// findAndapplyPreimage performs a non-blocking read to find the preimage for
+// the incoming HTLC. If found, it will be applied to the resolver. This method
+// is used for the resolver to decide whether it wants to transform into a
+// success resolver during launching.
+//
+// NOTE: Since we have two places to query the preimage, we need to check both
+// the preimage db and the invoice db to look up the preimage.
+func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) {
+ // Query to see if we already know the preimage.
+ preimage, ok := h.PreimageDB.LookupPreimage(h.htlc.RHash)
+
+ // If the preimage is known, we'll apply it.
+ if ok {
+ if err := h.applyPreimage(preimage); err != nil {
+ return false, err
+ }
+
+ // Successfully applied the preimage, we can now return.
+ return true, nil
+ }
+
+ // First try to parse the payload.
+ payload, _, err := h.decodePayload()
+ if err != nil {
+ h.log.Errorf("Cannot decode payload of htlc %v", h.HtlcPoint())
+
+ // If we cannot decode the payload, we will return a nil error
+ // and let it to be handled in `Resolve`.
+ return false, nil
+ }
+
+ // Exit early if this is not the exit hop, which means we are not the
+ // payment receiver and don't have preimage.
+ if payload.FwdInfo.NextHop != hop.Exit {
+ return false, nil
+ }
+
+ // Notify registry that we are potentially resolving as an exit hop
+ // on-chain. If this HTLC indeed pays to an existing invoice, the
+ // invoice registry will tell us what to do with the HTLC. This is
+ // identical to HTLC resolution in the link.
+ circuitKey := models.CircuitKey{
+ ChanID: h.ShortChanID,
+ HtlcID: h.htlc.HtlcIndex,
+ }
+
+ // Try get the resolution - if it doesn't give us a resolution
+ // immediately, we'll assume we don't know it yet and let the `Resolve`
+ // handle the waiting.
+ //
+ // NOTE: we use a nil subscriber here and a zero current height as we
+ // are only interested in the settle resolution.
+ //
+ // TODO(yy): move this logic to link and let the preimage be accessed
+ // via the preimage beacon.
+ resolution, err := h.Registry.NotifyExitHopHtlc(
+ h.htlc.RHash, h.htlc.Amt, h.htlcExpiry, 0,
+ circuitKey, nil, h.htlc.CustomRecords, payload,
+ )
+ if err != nil {
+ return false, err
+ }
+
+ res, ok := resolution.(*invoices.HtlcSettleResolution)
+
+ // Exit early if it's not a settle resolution.
+ if !ok {
+ return false, nil
+ }
+
+ // Otherwise we have a settle resolution, apply the preimage.
+ err = h.applyPreimage(res.Preimage)
+ if err != nil {
+ return false, err
+ }
+
+ return true, nil
+}
diff --git a/contractcourt/htlc_incoming_contest_resolver_test.go b/contractcourt/htlc_incoming_contest_resolver_test.go
index 22280f953e7..f17190e96e8 100644
--- a/contractcourt/htlc_incoming_contest_resolver_test.go
+++ b/contractcourt/htlc_incoming_contest_resolver_test.go
@@ -5,11 +5,13 @@ import (
"io"
"testing"
+ "github.com/btcsuite/btcd/wire"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/htlcswitch/hop"
+ "github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/kvdb"
"github.com/lightningnetwork/lnd/lnmock"
@@ -356,6 +358,7 @@ func newIncomingResolverTestContext(t *testing.T, isExit bool) *incomingResolver
return nil
},
+ Sweeper: newMockSweeper(),
},
PutResolverReport: func(_ kvdb.RwTx,
_ *channeldb.ResolverReport) error {
@@ -374,10 +377,16 @@ func newIncomingResolverTestContext(t *testing.T, isExit bool) *incomingResolver
},
}
+ res := lnwallet.IncomingHtlcResolution{
+ SweepSignDesc: input.SignDescriptor{
+ Output: &wire.TxOut{},
+ },
+ }
+
c.resolver = &htlcIncomingContestResolver{
htlcSuccessResolver: &htlcSuccessResolver{
contractResolverKit: *newContractResolverKit(cfg),
- htlcResolution: lnwallet.IncomingHtlcResolution{},
+ htlcResolution: res,
htlc: channeldb.HTLC{
Amt: lnwire.MilliSatoshi(testHtlcAmount),
RHash: testResHash,
@@ -386,6 +395,7 @@ func newIncomingResolverTestContext(t *testing.T, isExit bool) *incomingResolver
},
htlcExpiry: testHtlcExpiry,
}
+ c.resolver.initLogger("htlcIncomingContestResolver")
return c
}
@@ -395,7 +405,11 @@ func (i *incomingResolverTestContext) resolve() {
i.resolveErr = make(chan error, 1)
go func() {
var err error
- i.nextResolver, err = i.resolver.Resolve(false)
+
+ err = i.resolver.Launch()
+ require.NoError(i.t, err)
+
+ i.nextResolver, err = i.resolver.Resolve()
i.resolveErr <- err
}()
diff --git a/contractcourt/htlc_lease_resolver.go b/contractcourt/htlc_lease_resolver.go
index 9c5da6ee496..6230f96777b 100644
--- a/contractcourt/htlc_lease_resolver.go
+++ b/contractcourt/htlc_lease_resolver.go
@@ -57,10 +57,10 @@ func (h *htlcLeaseResolver) makeSweepInput(op *wire.OutPoint,
signDesc *input.SignDescriptor, csvDelay, broadcastHeight uint32,
payHash [32]byte, resBlob fn.Option[tlv.Blob]) *input.BaseInput {
- if h.hasCLTV() {
- log.Infof("%T(%x): CSV and CLTV locks expired, offering "+
- "second-layer output to sweeper: %v", h, payHash, op)
+ log.Infof("%T(%x): offering second-layer output to sweeper: %v", h,
+ payHash, op)
+ if h.hasCLTV() {
return input.NewCsvInputWithCltv(
op, cltvWtype, signDesc,
broadcastHeight, csvDelay,
diff --git a/contractcourt/htlc_outgoing_contest_resolver.go b/contractcourt/htlc_outgoing_contest_resolver.go
index 1303d0af600..9e94587cccb 100644
--- a/contractcourt/htlc_outgoing_contest_resolver.go
+++ b/contractcourt/htlc_outgoing_contest_resolver.go
@@ -1,7 +1,6 @@
package contractcourt
import (
- "fmt"
"io"
"github.com/btcsuite/btcd/btcutil"
@@ -36,6 +35,37 @@ func newOutgoingContestResolver(res lnwallet.OutgoingHtlcResolution,
}
}
+// Launch will call the inner resolver's launch method if the expiry height has
+// been reached, otherwise it's a no-op.
+func (h *htlcOutgoingContestResolver) Launch() error {
+ // NOTE: we don't mark this resolver as launched as the inner resolver
+ // will set it when it's launched.
+ if h.isLaunched() {
+ h.log.Tracef("already launched")
+ return nil
+ }
+
+ h.log.Debugf("launching contest resolver...")
+
+ _, bestHeight, err := h.ChainIO.GetBestBlock()
+ if err != nil {
+ return err
+ }
+
+ if uint32(bestHeight) < h.htlcResolution.Expiry {
+ return nil
+ }
+
+ // If the current height is >= expiry, then a timeout path spend will
+ // be valid to be included in the next block, and we can immediately
+ // return the resolver.
+ h.log.Infof("expired (height=%v, expiry=%v), transforming into "+
+ "timeout resolver and launching it", bestHeight,
+ h.htlcResolution.Expiry)
+
+ return h.htlcTimeoutResolver.Launch()
+}
+
// Resolve commences the resolution of this contract. As this contract hasn't
// yet timed out, we'll wait for one of two things to happen
//
@@ -49,12 +79,11 @@ func newOutgoingContestResolver(res lnwallet.OutgoingHtlcResolution,
// When either of these two things happens, we'll create a new resolver which
// is able to handle the final resolution of the contract. We're only the pivot
// point.
-func (h *htlcOutgoingContestResolver) Resolve(
- _ bool) (ContractResolver, error) {
-
+func (h *htlcOutgoingContestResolver) Resolve() (ContractResolver, error) {
// If we're already full resolved, then we don't have anything further
// to do.
- if h.resolved {
+ if h.IsResolved() {
+ h.log.Errorf("already resolved")
return nil, nil
}
@@ -88,8 +117,7 @@ func (h *htlcOutgoingContestResolver) Resolve(
return nil, errResolverShuttingDown
}
- // TODO(roasbeef): Checkpoint?
- return h.claimCleanUp(commitSpend)
+ return nil, h.claimCleanUp(commitSpend)
// If it hasn't, then we'll watch for both the expiration, and the
// sweeping out this output.
@@ -126,12 +154,20 @@ func (h *htlcOutgoingContestResolver) Resolve(
// finalized` will be returned and the broadcast will
// fail.
newHeight := uint32(newBlock.Height)
- if newHeight >= h.htlcResolution.Expiry {
- log.Infof("%T(%v): HTLC has expired "+
+ expiry := h.htlcResolution.Expiry
+
+ // Check if the expiry height is about to be reached.
+ // We offer this HTLC one block earlier to make sure
+ // when the next block arrives, the sweeper will pick
+ // up this input and sweep it immediately. The sweeper
+ // will handle the waiting for the one last block till
+ // expiry.
+ if newHeight >= expiry-1 {
+ h.log.Infof("HTLC about to expire "+
"(height=%v, expiry=%v), transforming "+
- "into timeout resolver", h,
- h.htlcResolution.ClaimOutpoint,
- newHeight, h.htlcResolution.Expiry)
+ "into timeout resolver", newHeight,
+ h.htlcResolution.Expiry)
+
return h.htlcTimeoutResolver, nil
}
@@ -146,10 +182,10 @@ func (h *htlcOutgoingContestResolver) Resolve(
// party is by revealing the preimage. So we'll perform
// our duties to clean up the contract once it has been
// claimed.
- return h.claimCleanUp(commitSpend)
+ return nil, h.claimCleanUp(commitSpend)
case <-h.quit:
- return nil, fmt.Errorf("resolver canceled")
+ return nil, errResolverShuttingDown
}
}
}
@@ -180,17 +216,11 @@ func (h *htlcOutgoingContestResolver) report() *ContractReport {
//
// NOTE: Part of the ContractResolver interface.
func (h *htlcOutgoingContestResolver) Stop() {
+ h.log.Debugf("stopping...")
+ defer h.log.Debugf("stopped")
close(h.quit)
}
-// IsResolved returns true if the stored state in the resolve is fully
-// resolved. In this case the target output can be forgotten.
-//
-// NOTE: Part of the ContractResolver interface.
-func (h *htlcOutgoingContestResolver) IsResolved() bool {
- return h.resolved
-}
-
// Encode writes an encoded version of the ContractResolver into the passed
// Writer.
//
diff --git a/contractcourt/htlc_outgoing_contest_resolver_test.go b/contractcourt/htlc_outgoing_contest_resolver_test.go
index 6608a6fb519..625df60bf1f 100644
--- a/contractcourt/htlc_outgoing_contest_resolver_test.go
+++ b/contractcourt/htlc_outgoing_contest_resolver_test.go
@@ -15,6 +15,7 @@ import (
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/require"
)
const (
@@ -159,6 +160,7 @@ func newOutgoingResolverTestContext(t *testing.T) *outgoingResolverTestContext {
return nil
},
+ ChainIO: &mock.ChainIO{},
},
PutResolverReport: func(_ kvdb.RwTx,
_ *channeldb.ResolverReport) error {
@@ -195,6 +197,7 @@ func newOutgoingResolverTestContext(t *testing.T) *outgoingResolverTestContext {
},
},
}
+ resolver.initLogger("htlcOutgoingContestResolver")
return &outgoingResolverTestContext{
resolver: resolver,
@@ -209,7 +212,10 @@ func (i *outgoingResolverTestContext) resolve() {
// Start resolver.
i.resolverResultChan = make(chan resolveResult, 1)
go func() {
- nextResolver, err := i.resolver.Resolve(false)
+ err := i.resolver.Launch()
+ require.NoError(i.t, err)
+
+ nextResolver, err := i.resolver.Resolve()
i.resolverResultChan <- resolveResult{
nextResolver: nextResolver,
err: err,
diff --git a/contractcourt/htlc_success_resolver.go b/contractcourt/htlc_success_resolver.go
index 9d09f844dcb..a4d27ba4e81 100644
--- a/contractcourt/htlc_success_resolver.go
+++ b/contractcourt/htlc_success_resolver.go
@@ -2,6 +2,7 @@ package contractcourt
import (
"encoding/binary"
+ "fmt"
"io"
"sync"
@@ -9,8 +10,6 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
- "github.com/davecgh/go-spew/spew"
- "github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
@@ -43,9 +42,6 @@ type htlcSuccessResolver struct {
// second-level output (true).
outputIncubating bool
- // resolved reflects if the contract has been fully resolved or not.
- resolved bool
-
// broadcastHeight is the height that the original contract was
// broadcast to the main-chain at. We'll use this value to bound any
// historical queries to the chain for spends/confirmations.
@@ -81,27 +77,30 @@ func newSuccessResolver(res lnwallet.IncomingHtlcResolution,
}
h.initReport()
+ h.initLogger(fmt.Sprintf("%T(%v)", h, h.outpoint()))
return h
}
-// ResolverKey returns an identifier which should be globally unique for this
-// particular resolver within the chain the original contract resides within.
-//
-// NOTE: Part of the ContractResolver interface.
-func (h *htlcSuccessResolver) ResolverKey() []byte {
+// outpoint returns the outpoint of the HTLC output we're attempting to sweep.
+func (h *htlcSuccessResolver) outpoint() wire.OutPoint {
// The primary key for this resolver will be the outpoint of the HTLC
// on the commitment transaction itself. If this is our commitment,
// then the output can be found within the signed success tx,
// otherwise, it's just the ClaimOutpoint.
- var op wire.OutPoint
if h.htlcResolution.SignedSuccessTx != nil {
- op = h.htlcResolution.SignedSuccessTx.TxIn[0].PreviousOutPoint
- } else {
- op = h.htlcResolution.ClaimOutpoint
+ return h.htlcResolution.SignedSuccessTx.TxIn[0].PreviousOutPoint
}
- key := newResolverID(op)
+ return h.htlcResolution.ClaimOutpoint
+}
+
+// ResolverKey returns an identifier which should be globally unique for this
+// particular resolver within the chain the original contract resides within.
+//
+// NOTE: Part of the ContractResolver interface.
+func (h *htlcSuccessResolver) ResolverKey() []byte {
+ key := newResolverID(h.outpoint())
return key[:]
}
@@ -112,423 +111,66 @@ func (h *htlcSuccessResolver) ResolverKey() []byte {
// anymore. Every HTLC has already passed through the incoming contest resolver
// and in there the invoice was already marked as settled.
//
-// TODO(roasbeef): create multi to batch
-//
// NOTE: Part of the ContractResolver interface.
-func (h *htlcSuccessResolver) Resolve(
- immediate bool) (ContractResolver, error) {
-
- // If we're already resolved, then we can exit early.
- if h.resolved {
- return nil, nil
- }
-
- // If we don't have a success transaction, then this means that this is
- // an output on the remote party's commitment transaction.
- if h.htlcResolution.SignedSuccessTx == nil {
- return h.resolveRemoteCommitOutput(immediate)
- }
-
- // Otherwise this an output on our own commitment, and we must start by
- // broadcasting the second-level success transaction.
- secondLevelOutpoint, err := h.broadcastSuccessTx(immediate)
- if err != nil {
- return nil, err
- }
-
- // To wrap this up, we'll wait until the second-level transaction has
- // been spent, then fully resolve the contract.
- log.Infof("%T(%x): waiting for second-level HTLC output to be spent "+
- "after csv_delay=%v", h, h.htlc.RHash[:], h.htlcResolution.CsvDelay)
-
- spend, err := waitForSpend(
- secondLevelOutpoint,
- h.htlcResolution.SweepSignDesc.Output.PkScript,
- h.broadcastHeight, h.Notifier, h.quit,
- )
- if err != nil {
- return nil, err
- }
-
- h.reportLock.Lock()
- h.currentReport.RecoveredBalance = h.currentReport.LimboBalance
- h.currentReport.LimboBalance = 0
- h.reportLock.Unlock()
-
- h.resolved = true
- return nil, h.checkpointClaim(
- spend.SpenderTxHash, channeldb.ResolverOutcomeClaimed,
- )
-}
-
-// broadcastSuccessTx handles an HTLC output on our local commitment by
-// broadcasting the second-level success transaction. It returns the ultimate
-// outpoint of the second-level tx, that we must wait to be spent for the
-// resolver to be fully resolved.
-func (h *htlcSuccessResolver) broadcastSuccessTx(
- immediate bool) (*wire.OutPoint, error) {
-
- // If we have non-nil SignDetails, this means that have a 2nd level
- // HTLC transaction that is signed using sighash SINGLE|ANYONECANPAY
- // (the case for anchor type channels). In this case we can re-sign it
- // and attach fees at will. We let the sweeper handle this job. We use
- // the checkpointed outputIncubating field to determine if we already
- // swept the HTLC output into the second level transaction.
- if h.htlcResolution.SignDetails != nil {
- return h.broadcastReSignedSuccessTx(immediate)
- }
-
- // Otherwise we'll publish the second-level transaction directly and
- // offer the resolution to the nursery to handle.
- log.Infof("%T(%x): broadcasting second-layer transition tx: %v",
- h, h.htlc.RHash[:], spew.Sdump(h.htlcResolution.SignedSuccessTx))
-
- // We'll now broadcast the second layer transaction so we can kick off
- // the claiming process.
- //
- // TODO(roasbeef): after changing sighashes send to tx bundler
- label := labels.MakeLabel(
- labels.LabelTypeChannelClose, &h.ShortChanID,
- )
- err := h.PublishTx(h.htlcResolution.SignedSuccessTx, label)
- if err != nil {
- return nil, err
- }
-
- // Otherwise, this is an output on our commitment transaction. In this
- // case, we'll send it to the incubator, but only if we haven't already
- // done so.
- if !h.outputIncubating {
- log.Infof("%T(%x): incubating incoming htlc output",
- h, h.htlc.RHash[:])
-
- err := h.IncubateOutputs(
- h.ChanPoint, fn.None[lnwallet.OutgoingHtlcResolution](),
- fn.Some(h.htlcResolution),
- h.broadcastHeight, fn.Some(int32(h.htlc.RefundTimeout)),
- )
- if err != nil {
- return nil, err
- }
-
- h.outputIncubating = true
-
- if err := h.Checkpoint(h); err != nil {
- log.Errorf("unable to Checkpoint: %v", err)
- return nil, err
- }
- }
-
- return &h.htlcResolution.ClaimOutpoint, nil
-}
-
-// broadcastReSignedSuccessTx handles the case where we have non-nil
-// SignDetails, and offers the second level transaction to the Sweeper, that
-// will re-sign it and attach fees at will.
//
-//nolint:funlen
-func (h *htlcSuccessResolver) broadcastReSignedSuccessTx(immediate bool) (
- *wire.OutPoint, error) {
-
- // Keep track of the tx spending the HTLC output on the commitment, as
- // this will be the confirmed second-level tx we'll ultimately sweep.
- var commitSpend *chainntnfs.SpendDetail
-
- // We will have to let the sweeper re-sign the success tx and wait for
- // it to confirm, if we haven't already.
- isTaproot := txscript.IsPayToTaproot(
- h.htlcResolution.SweepSignDesc.Output.PkScript,
- )
- if !h.outputIncubating {
- var secondLevelInput input.HtlcSecondLevelAnchorInput
- if isTaproot {
- //nolint:ll
- secondLevelInput = input.MakeHtlcSecondLevelSuccessTaprootInput(
- h.htlcResolution.SignedSuccessTx,
- h.htlcResolution.SignDetails, h.htlcResolution.Preimage,
- h.broadcastHeight,
- input.WithResolutionBlob(
- h.htlcResolution.ResolutionBlob,
- ),
- )
- } else {
- //nolint:ll
- secondLevelInput = input.MakeHtlcSecondLevelSuccessAnchorInput(
- h.htlcResolution.SignedSuccessTx,
- h.htlcResolution.SignDetails, h.htlcResolution.Preimage,
- h.broadcastHeight,
- )
- }
-
- // Calculate the budget for this sweep.
- value := btcutil.Amount(
- secondLevelInput.SignDesc().Output.Value,
- )
- budget := calculateBudget(
- value, h.Budget.DeadlineHTLCRatio,
- h.Budget.DeadlineHTLC,
- )
-
- // The deadline would be the CLTV in this HTLC output. If we
- // are the initiator of this force close, with the default
- // `IncomingBroadcastDelta`, it means we have 10 blocks left
- // when going onchain. Given we need to mine one block to
- // confirm the force close tx, and one more block to trigger
- // the sweep, we have 8 blocks left to sweep the HTLC.
- deadline := fn.Some(int32(h.htlc.RefundTimeout))
-
- log.Infof("%T(%x): offering second-level HTLC success tx to "+
- "sweeper with deadline=%v, budget=%v", h,
- h.htlc.RHash[:], h.htlc.RefundTimeout, budget)
-
- // We'll now offer the second-level transaction to the sweeper.
- _, err := h.Sweeper.SweepInput(
- &secondLevelInput,
- sweep.Params{
- Budget: budget,
- DeadlineHeight: deadline,
- Immediate: immediate,
- },
- )
- if err != nil {
- return nil, err
- }
-
- log.Infof("%T(%x): waiting for second-level HTLC success "+
- "transaction to confirm", h, h.htlc.RHash[:])
-
- // Wait for the second level transaction to confirm.
- commitSpend, err = waitForSpend(
- &h.htlcResolution.SignedSuccessTx.TxIn[0].PreviousOutPoint,
- h.htlcResolution.SignDetails.SignDesc.Output.PkScript,
- h.broadcastHeight, h.Notifier, h.quit,
- )
- if err != nil {
- return nil, err
- }
+// TODO(yy): refactor the interface method to return an error only.
+func (h *htlcSuccessResolver) Resolve() (ContractResolver, error) {
+ var err error
- // Now that the second-level transaction has confirmed, we
- // checkpoint the state so we'll go to the next stage in case
- // of restarts.
- h.outputIncubating = true
- if err := h.Checkpoint(h); err != nil {
- log.Errorf("unable to Checkpoint: %v", err)
- return nil, err
- }
-
- log.Infof("%T(%x): second-level HTLC success transaction "+
- "confirmed!", h, h.htlc.RHash[:])
- }
-
- // If we ended up here after a restart, we must again get the
- // spend notification.
- if commitSpend == nil {
- var err error
- commitSpend, err = waitForSpend(
- &h.htlcResolution.SignedSuccessTx.TxIn[0].PreviousOutPoint,
- h.htlcResolution.SignDetails.SignDesc.Output.PkScript,
- h.broadcastHeight, h.Notifier, h.quit,
- )
- if err != nil {
- return nil, err
- }
- }
-
- // The HTLC success tx has a CSV lock that we must wait for, and if
- // this is a lease enforced channel and we're the imitator, we may need
- // to wait for longer.
- waitHeight := h.deriveWaitHeight(
- h.htlcResolution.CsvDelay, commitSpend,
- )
-
- // Now that the sweeper has broadcasted the second-level transaction,
- // it has confirmed, and we have checkpointed our state, we'll sweep
- // the second level output. We report the resolver has moved the next
- // stage.
- h.reportLock.Lock()
- h.currentReport.Stage = 2
- h.currentReport.MaturityHeight = waitHeight
- h.reportLock.Unlock()
-
- if h.hasCLTV() {
- log.Infof("%T(%x): waiting for CSV and CLTV lock to "+
- "expire at height %v", h, h.htlc.RHash[:],
- waitHeight)
- } else {
- log.Infof("%T(%x): waiting for CSV lock to expire at "+
- "height %v", h, h.htlc.RHash[:], waitHeight)
- }
-
- // Deduct one block so this input is offered to the sweeper one block
- // earlier since the sweeper will wait for one block to trigger the
- // sweeping.
- //
- // TODO(yy): this is done so the outputs can be aggregated
- // properly. Suppose CSV locks of five 2nd-level outputs all
- // expire at height 840000, there is a race in block digestion
- // between contractcourt and sweeper:
- // - G1: block 840000 received in contractcourt, it now offers
- // the outputs to the sweeper.
- // - G2: block 840000 received in sweeper, it now starts to
- // sweep the received outputs - there's no guarantee all
- // fives have been received.
- // To solve this, we either offer the outputs earlier, or
- // implement `blockbeat`, and force contractcourt and sweeper
- // to consume each block sequentially.
- waitHeight--
-
- // TODO(yy): let sweeper handles the wait?
- err := waitForHeight(waitHeight, h.Notifier, h.quit)
- if err != nil {
- return nil, err
- }
-
- // We'll use this input index to determine the second-level output
- // index on the transaction, as the signatures requires the indexes to
- // be the same. We don't look for the second-level output script
- // directly, as there might be more than one HTLC output to the same
- // pkScript.
- op := &wire.OutPoint{
- Hash: *commitSpend.SpenderTxHash,
- Index: commitSpend.SpenderInputIndex,
- }
-
- // Let the sweeper sweep the second-level output now that the
- // CSV/CLTV locks have expired.
- var witType input.StandardWitnessType
- if isTaproot {
- witType = input.TaprootHtlcAcceptedSuccessSecondLevel
- } else {
- witType = input.HtlcAcceptedSuccessSecondLevel
- }
- inp := h.makeSweepInput(
- op, witType,
- input.LeaseHtlcAcceptedSuccessSecondLevel,
- &h.htlcResolution.SweepSignDesc,
- h.htlcResolution.CsvDelay, uint32(commitSpend.SpendingHeight),
- h.htlc.RHash, h.htlcResolution.ResolutionBlob,
- )
-
- // Calculate the budget for this sweep.
- budget := calculateBudget(
- btcutil.Amount(inp.SignDesc().Output.Value),
- h.Budget.NoDeadlineHTLCRatio,
- h.Budget.NoDeadlineHTLC,
- )
+ switch {
+ // If we're already resolved, then we can exit early.
+ case h.IsResolved():
+ h.log.Errorf("already resolved")
- log.Infof("%T(%x): offering second-level success tx output to sweeper "+
- "with no deadline and budget=%v at height=%v", h,
- h.htlc.RHash[:], budget, waitHeight)
+ // If this is an output on the remote party's commitment transaction,
+ // use the direct-spend path to sweep the htlc.
+ case h.isRemoteCommitOutput():
+ err = h.resolveRemoteCommitOutput()
- // TODO(roasbeef): need to update above for leased types
- _, err = h.Sweeper.SweepInput(
- inp,
- sweep.Params{
- Budget: budget,
+ // If this is an output on our commitment transaction using post-anchor
+ // channel type, it will be handled by the sweeper.
+ case h.isZeroFeeOutput():
+ err = h.resolveSuccessTx()
- // For second level success tx, there's no rush to get
- // it confirmed, so we use a nil deadline.
- DeadlineHeight: fn.None[int32](),
- },
- )
- if err != nil {
- return nil, err
+ // If this is an output on our own commitment using pre-anchor channel
+ // type, we will publish the success tx and offer the output to the
+ // nursery.
+ default:
+ err = h.resolveLegacySuccessTx()
}
- // Will return this outpoint, when this is spent the resolver is fully
- // resolved.
- return op, nil
+ return nil, err
}
// resolveRemoteCommitOutput handles sweeping an HTLC output on the remote
// commitment with the preimage. In this case we can sweep the output directly,
// and don't have to broadcast a second-level transaction.
-func (h *htlcSuccessResolver) resolveRemoteCommitOutput(immediate bool) (
- ContractResolver, error) {
-
- isTaproot := txscript.IsPayToTaproot(
- h.htlcResolution.SweepSignDesc.Output.PkScript,
- )
-
- // Before we can craft out sweeping transaction, we need to
- // create an input which contains all the items required to add
- // this input to a sweeping transaction, and generate a
- // witness.
- var inp input.Input
- if isTaproot {
- inp = lnutils.Ptr(input.MakeTaprootHtlcSucceedInput(
- &h.htlcResolution.ClaimOutpoint,
- &h.htlcResolution.SweepSignDesc,
- h.htlcResolution.Preimage[:],
- h.broadcastHeight,
- h.htlcResolution.CsvDelay,
- input.WithResolutionBlob(
- h.htlcResolution.ResolutionBlob,
- ),
- ))
- } else {
- inp = lnutils.Ptr(input.MakeHtlcSucceedInput(
- &h.htlcResolution.ClaimOutpoint,
- &h.htlcResolution.SweepSignDesc,
- h.htlcResolution.Preimage[:],
- h.broadcastHeight,
- h.htlcResolution.CsvDelay,
- ))
- }
-
- // Calculate the budget for this sweep.
- budget := calculateBudget(
- btcutil.Amount(inp.SignDesc().Output.Value),
- h.Budget.DeadlineHTLCRatio,
- h.Budget.DeadlineHTLC,
- )
-
- deadline := fn.Some(int32(h.htlc.RefundTimeout))
-
- log.Infof("%T(%x): offering direct-preimage HTLC output to sweeper "+
- "with deadline=%v, budget=%v", h, h.htlc.RHash[:],
- h.htlc.RefundTimeout, budget)
-
- // We'll now offer the direct preimage HTLC to the sweeper.
- _, err := h.Sweeper.SweepInput(
- inp,
- sweep.Params{
- Budget: budget,
- DeadlineHeight: deadline,
- Immediate: immediate,
- },
- )
- if err != nil {
- return nil, err
- }
+func (h *htlcSuccessResolver) resolveRemoteCommitOutput() error {
+ h.log.Info("waiting for direct-preimage spend of the htlc to confirm")
// Wait for the direct-preimage HTLC sweep tx to confirm.
+ //
+ // TODO(yy): use the result chan returned from `SweepInput`.
sweepTxDetails, err := waitForSpend(
&h.htlcResolution.ClaimOutpoint,
h.htlcResolution.SweepSignDesc.Output.PkScript,
h.broadcastHeight, h.Notifier, h.quit,
)
if err != nil {
- return nil, err
+ return err
}
- // Once the transaction has received a sufficient number of
- // confirmations, we'll mark ourselves as fully resolved and exit.
- h.resolved = true
+ // TODO(yy): should also update the `RecoveredBalance` and
+ // `LimboBalance` like other paths?
// Checkpoint the resolver, and write the outcome to disk.
- return nil, h.checkpointClaim(
- sweepTxDetails.SpenderTxHash,
- channeldb.ResolverOutcomeClaimed,
- )
+ return h.checkpointClaim(sweepTxDetails.SpenderTxHash)
}
// checkpointClaim checkpoints the success resolver with the reports it needs.
// If this htlc was claimed two stages, it will write reports for both stages,
// otherwise it will just write for the single htlc claim.
-func (h *htlcSuccessResolver) checkpointClaim(spendTx *chainhash.Hash,
- outcome channeldb.ResolverOutcome) error {
-
+func (h *htlcSuccessResolver) checkpointClaim(spendTx *chainhash.Hash) error {
// Mark the htlc as final settled.
err := h.ChainArbitratorConfig.PutFinalHtlcOutcome(
h.ChannelArbitratorConfig.ShortChanID, h.htlc.HtlcIndex, true,
@@ -556,7 +198,7 @@ func (h *htlcSuccessResolver) checkpointClaim(spendTx *chainhash.Hash,
OutPoint: h.htlcResolution.ClaimOutpoint,
Amount: amt,
ResolverType: channeldb.ResolverTypeIncomingHtlc,
- ResolverOutcome: outcome,
+ ResolverOutcome: channeldb.ResolverOutcomeClaimed,
SpendTxID: spendTx,
},
}
@@ -581,6 +223,7 @@ func (h *htlcSuccessResolver) checkpointClaim(spendTx *chainhash.Hash,
}
// Finally, we checkpoint the resolver with our report(s).
+ h.markResolved()
return h.Checkpoint(h, reports...)
}
@@ -589,15 +232,10 @@ func (h *htlcSuccessResolver) checkpointClaim(spendTx *chainhash.Hash,
//
// NOTE: Part of the ContractResolver interface.
func (h *htlcSuccessResolver) Stop() {
- close(h.quit)
-}
+ h.log.Debugf("stopping...")
+ defer h.log.Debugf("stopped")
-// IsResolved returns true if the stored state in the resolve is fully
-// resolved. In this case the target output can be forgotten.
-//
-// NOTE: Part of the ContractResolver interface.
-func (h *htlcSuccessResolver) IsResolved() bool {
- return h.resolved
+ close(h.quit)
}
// report returns a report on the resolution state of the contract.
@@ -649,7 +287,7 @@ func (h *htlcSuccessResolver) Encode(w io.Writer) error {
if err := binary.Write(w, endian, h.outputIncubating); err != nil {
return err
}
- if err := binary.Write(w, endian, h.resolved); err != nil {
+ if err := binary.Write(w, endian, h.IsResolved()); err != nil {
return err
}
if err := binary.Write(w, endian, h.broadcastHeight); err != nil {
@@ -688,9 +326,15 @@ func newSuccessResolverFromReader(r io.Reader, resCfg ResolverConfig) (
if err := binary.Read(r, endian, &h.outputIncubating); err != nil {
return nil, err
}
- if err := binary.Read(r, endian, &h.resolved); err != nil {
+
+ var resolved bool
+ if err := binary.Read(r, endian, &resolved); err != nil {
return nil, err
}
+ if resolved {
+ h.markResolved()
+ }
+
if err := binary.Read(r, endian, &h.broadcastHeight); err != nil {
return nil, err
}
@@ -709,6 +353,7 @@ func newSuccessResolverFromReader(r io.Reader, resCfg ResolverConfig) (
}
h.initReport()
+ h.initLogger(fmt.Sprintf("%T(%v)", h, h.outpoint()))
return h, nil
}
@@ -737,3 +382,391 @@ func (h *htlcSuccessResolver) SupplementDeadline(_ fn.Option[int32]) {
// A compile time assertion to ensure htlcSuccessResolver meets the
// ContractResolver interface.
var _ htlcContractResolver = (*htlcSuccessResolver)(nil)
+
+// isRemoteCommitOutput returns a bool to indicate whether the htlc output is
+// on the remote commitment.
+func (h *htlcSuccessResolver) isRemoteCommitOutput() bool {
+ // If we don't have a success transaction, then this means that this is
+ // an output on the remote party's commitment transaction.
+ return h.htlcResolution.SignedSuccessTx == nil
+}
+
+// isZeroFeeOutput returns a boolean indicating whether the htlc output is from
+// a anchor-enabled channel, which uses the sighash SINGLE|ANYONECANPAY.
+func (h *htlcSuccessResolver) isZeroFeeOutput() bool {
+ // If we have non-nil SignDetails, this means it has a 2nd level HTLC
+ // transaction that is signed using sighash SINGLE|ANYONECANPAY (the
+ // case for anchor type channels). In this case we can re-sign it and
+ // attach fees at will.
+ return h.htlcResolution.SignedSuccessTx != nil &&
+ h.htlcResolution.SignDetails != nil
+}
+
+// isTaproot returns true if the resolver is for a taproot output.
+func (h *htlcSuccessResolver) isTaproot() bool {
+ return txscript.IsPayToTaproot(
+ h.htlcResolution.SweepSignDesc.Output.PkScript,
+ )
+}
+
+// sweepRemoteCommitOutput creates a sweep request to sweep the HTLC output on
+// the remote commitment via the direct preimage-spend.
+func (h *htlcSuccessResolver) sweepRemoteCommitOutput() error {
+ // Before we can craft out sweeping transaction, we need to create an
+ // input which contains all the items required to add this input to a
+ // sweeping transaction, and generate a witness.
+ var inp input.Input
+
+ if h.isTaproot() {
+ inp = lnutils.Ptr(input.MakeTaprootHtlcSucceedInput(
+ &h.htlcResolution.ClaimOutpoint,
+ &h.htlcResolution.SweepSignDesc,
+ h.htlcResolution.Preimage[:],
+ h.broadcastHeight,
+ h.htlcResolution.CsvDelay,
+ input.WithResolutionBlob(
+ h.htlcResolution.ResolutionBlob,
+ ),
+ ))
+ } else {
+ inp = lnutils.Ptr(input.MakeHtlcSucceedInput(
+ &h.htlcResolution.ClaimOutpoint,
+ &h.htlcResolution.SweepSignDesc,
+ h.htlcResolution.Preimage[:],
+ h.broadcastHeight,
+ h.htlcResolution.CsvDelay,
+ ))
+ }
+
+ // Calculate the budget for this sweep.
+ budget := calculateBudget(
+ btcutil.Amount(inp.SignDesc().Output.Value),
+ h.Budget.DeadlineHTLCRatio,
+ h.Budget.DeadlineHTLC,
+ )
+
+ deadline := fn.Some(int32(h.htlc.RefundTimeout))
+
+ log.Infof("%T(%x): offering direct-preimage HTLC output to sweeper "+
+ "with deadline=%v, budget=%v", h, h.htlc.RHash[:],
+ h.htlc.RefundTimeout, budget)
+
+ // We'll now offer the direct preimage HTLC to the sweeper.
+ _, err := h.Sweeper.SweepInput(
+ inp,
+ sweep.Params{
+ Budget: budget,
+ DeadlineHeight: deadline,
+ },
+ )
+
+ return err
+}
+
+// sweepSuccessTx attempts to sweep the second level success tx.
+func (h *htlcSuccessResolver) sweepSuccessTx() error {
+ var secondLevelInput input.HtlcSecondLevelAnchorInput
+ if h.isTaproot() {
+ secondLevelInput = input.MakeHtlcSecondLevelSuccessTaprootInput(
+ h.htlcResolution.SignedSuccessTx,
+ h.htlcResolution.SignDetails, h.htlcResolution.Preimage,
+ h.broadcastHeight, input.WithResolutionBlob(
+ h.htlcResolution.ResolutionBlob,
+ ),
+ )
+ } else {
+ secondLevelInput = input.MakeHtlcSecondLevelSuccessAnchorInput(
+ h.htlcResolution.SignedSuccessTx,
+ h.htlcResolution.SignDetails, h.htlcResolution.Preimage,
+ h.broadcastHeight,
+ )
+ }
+
+ // Calculate the budget for this sweep.
+ value := btcutil.Amount(secondLevelInput.SignDesc().Output.Value)
+ budget := calculateBudget(
+ value, h.Budget.DeadlineHTLCRatio, h.Budget.DeadlineHTLC,
+ )
+
+ // The deadline would be the CLTV in this HTLC output. If we are the
+ // initiator of this force close, with the default
+ // `IncomingBroadcastDelta`, it means we have 10 blocks left when going
+ // onchain.
+ deadline := fn.Some(int32(h.htlc.RefundTimeout))
+
+ h.log.Infof("offering second-level HTLC success tx to sweeper with "+
+ "deadline=%v, budget=%v", h.htlc.RefundTimeout, budget)
+
+ // We'll now offer the second-level transaction to the sweeper.
+ _, err := h.Sweeper.SweepInput(
+ &secondLevelInput,
+ sweep.Params{
+ Budget: budget,
+ DeadlineHeight: deadline,
+ },
+ )
+
+ return err
+}
+
+// sweepSuccessTxOutput attempts to sweep the output of the second level
+// success tx.
+func (h *htlcSuccessResolver) sweepSuccessTxOutput() error {
+ h.log.Debugf("sweeping output %v from 2nd-level HTLC success tx",
+ h.htlcResolution.ClaimOutpoint)
+
+ // This should be non-blocking as we will only attempt to sweep the
+ // output when the second level tx has already been confirmed. In other
+ // words, waitForSpend will return immediately.
+ commitSpend, err := waitForSpend(
+ &h.htlcResolution.SignedSuccessTx.TxIn[0].PreviousOutPoint,
+ h.htlcResolution.SignDetails.SignDesc.Output.PkScript,
+ h.broadcastHeight, h.Notifier, h.quit,
+ )
+ if err != nil {
+ return err
+ }
+
+ // The HTLC success tx has a CSV lock that we must wait for, and if
+ // this is a lease enforced channel and we're the imitator, we may need
+ // to wait for longer.
+ waitHeight := h.deriveWaitHeight(h.htlcResolution.CsvDelay, commitSpend)
+
+ // Now that the sweeper has broadcasted the second-level transaction,
+ // it has confirmed, and we have checkpointed our state, we'll sweep
+ // the second level output. We report the resolver has moved the next
+ // stage.
+ h.reportLock.Lock()
+ h.currentReport.Stage = 2
+ h.currentReport.MaturityHeight = waitHeight
+ h.reportLock.Unlock()
+
+ if h.hasCLTV() {
+ log.Infof("%T(%x): waiting for CSV and CLTV lock to expire at "+
+ "height %v", h, h.htlc.RHash[:], waitHeight)
+ } else {
+ log.Infof("%T(%x): waiting for CSV lock to expire at height %v",
+ h, h.htlc.RHash[:], waitHeight)
+ }
+
+ // We'll use this input index to determine the second-level output
+ // index on the transaction, as the signatures requires the indexes to
+ // be the same. We don't look for the second-level output script
+ // directly, as there might be more than one HTLC output to the same
+ // pkScript.
+ op := &wire.OutPoint{
+ Hash: *commitSpend.SpenderTxHash,
+ Index: commitSpend.SpenderInputIndex,
+ }
+
+ // Let the sweeper sweep the second-level output now that the
+ // CSV/CLTV locks have expired.
+ var witType input.StandardWitnessType
+ if h.isTaproot() {
+ witType = input.TaprootHtlcAcceptedSuccessSecondLevel
+ } else {
+ witType = input.HtlcAcceptedSuccessSecondLevel
+ }
+ inp := h.makeSweepInput(
+ op, witType,
+ input.LeaseHtlcAcceptedSuccessSecondLevel,
+ &h.htlcResolution.SweepSignDesc,
+ h.htlcResolution.CsvDelay, uint32(commitSpend.SpendingHeight),
+ h.htlc.RHash, h.htlcResolution.ResolutionBlob,
+ )
+
+ // Calculate the budget for this sweep.
+ budget := calculateBudget(
+ btcutil.Amount(inp.SignDesc().Output.Value),
+ h.Budget.NoDeadlineHTLCRatio,
+ h.Budget.NoDeadlineHTLC,
+ )
+
+ log.Infof("%T(%x): offering second-level success tx output to sweeper "+
+ "with no deadline and budget=%v at height=%v", h,
+ h.htlc.RHash[:], budget, waitHeight)
+
+ // TODO(yy): use the result chan returned from SweepInput.
+ _, err = h.Sweeper.SweepInput(
+ inp,
+ sweep.Params{
+ Budget: budget,
+
+ // For second level success tx, there's no rush to get
+ // it confirmed, so we use a nil deadline.
+ DeadlineHeight: fn.None[int32](),
+ },
+ )
+
+ return err
+}
+
+// resolveLegacySuccessTx handles an HTLC output from a pre-anchor type channel
+// by broadcasting the second-level success transaction.
+func (h *htlcSuccessResolver) resolveLegacySuccessTx() error {
+ // Otherwise we'll publish the second-level transaction directly and
+ // offer the resolution to the nursery to handle.
+ h.log.Infof("broadcasting legacy second-level success tx: %v",
+ h.htlcResolution.SignedSuccessTx.TxHash())
+
+ // We'll now broadcast the second layer transaction so we can kick off
+ // the claiming process.
+ //
+ // TODO(yy): offer it to the sweeper instead.
+ label := labels.MakeLabel(
+ labels.LabelTypeChannelClose, &h.ShortChanID,
+ )
+ err := h.PublishTx(h.htlcResolution.SignedSuccessTx, label)
+ if err != nil {
+ return err
+ }
+
+ // Fast-forward to resolve the output from the success tx if the it has
+ // already been sent to the UtxoNursery.
+ if h.outputIncubating {
+ return h.resolveSuccessTxOutput(h.htlcResolution.ClaimOutpoint)
+ }
+
+ h.log.Infof("incubating incoming htlc output")
+
+ // Send the output to the incubator.
+ err = h.IncubateOutputs(
+ h.ChanPoint, fn.None[lnwallet.OutgoingHtlcResolution](),
+ fn.Some(h.htlcResolution),
+ h.broadcastHeight, fn.Some(int32(h.htlc.RefundTimeout)),
+ )
+ if err != nil {
+ return err
+ }
+
+ // Mark the output as incubating and checkpoint it.
+ h.outputIncubating = true
+ if err := h.Checkpoint(h); err != nil {
+ return err
+ }
+
+ // Move to resolve the output.
+ return h.resolveSuccessTxOutput(h.htlcResolution.ClaimOutpoint)
+}
+
+// resolveSuccessTx waits for the sweeping tx of the second-level success tx to
+// confirm and offers the output from the success tx to the sweeper.
+func (h *htlcSuccessResolver) resolveSuccessTx() error {
+ h.log.Infof("waiting for 2nd-level HTLC success transaction to confirm")
+
+ // Create aliases to make the code more readable.
+ outpoint := h.htlcResolution.SignedSuccessTx.TxIn[0].PreviousOutPoint
+ pkScript := h.htlcResolution.SignDetails.SignDesc.Output.PkScript
+
+ // Wait for the second level transaction to confirm.
+ commitSpend, err := waitForSpend(
+ &outpoint, pkScript, h.broadcastHeight, h.Notifier, h.quit,
+ )
+ if err != nil {
+ return err
+ }
+
+ // We'll use this input index to determine the second-level output
+ // index on the transaction, as the signatures requires the indexes to
+ // be the same. We don't look for the second-level output script
+ // directly, as there might be more than one HTLC output to the same
+ // pkScript.
+ op := wire.OutPoint{
+ Hash: *commitSpend.SpenderTxHash,
+ Index: commitSpend.SpenderInputIndex,
+ }
+
+ // If the 2nd-stage sweeping has already been started, we can
+ // fast-forward to start the resolving process for the stage two
+ // output.
+ if h.outputIncubating {
+ return h.resolveSuccessTxOutput(op)
+ }
+
+ // Now that the second-level transaction has confirmed, we checkpoint
+ // the state so we'll go to the next stage in case of restarts.
+ h.outputIncubating = true
+ if err := h.Checkpoint(h); err != nil {
+ log.Errorf("unable to Checkpoint: %v", err)
+ return err
+ }
+
+ h.log.Infof("2nd-level HTLC success tx=%v confirmed",
+ commitSpend.SpenderTxHash)
+
+ // Send the sweep request for the output from the success tx.
+ if err := h.sweepSuccessTxOutput(); err != nil {
+ return err
+ }
+
+ return h.resolveSuccessTxOutput(op)
+}
+
+// resolveSuccessTxOutput waits for the spend of the output from the 2nd-level
+// success tx.
+func (h *htlcSuccessResolver) resolveSuccessTxOutput(op wire.OutPoint) error {
+ // To wrap this up, we'll wait until the second-level transaction has
+ // been spent, then fully resolve the contract.
+ log.Infof("%T(%x): waiting for second-level HTLC output to be spent "+
+ "after csv_delay=%v", h, h.htlc.RHash[:],
+ h.htlcResolution.CsvDelay)
+
+ spend, err := waitForSpend(
+ &op, h.htlcResolution.SweepSignDesc.Output.PkScript,
+ h.broadcastHeight, h.Notifier, h.quit,
+ )
+ if err != nil {
+ return err
+ }
+
+ h.reportLock.Lock()
+ h.currentReport.RecoveredBalance = h.currentReport.LimboBalance
+ h.currentReport.LimboBalance = 0
+ h.reportLock.Unlock()
+
+ return h.checkpointClaim(spend.SpenderTxHash)
+}
+
+// Launch creates an input based on the details of the incoming htlc resolution
+// and offers it to the sweeper.
+func (h *htlcSuccessResolver) Launch() error {
+ if h.isLaunched() {
+ h.log.Tracef("already launched")
+ return nil
+ }
+
+ h.log.Debugf("launching resolver...")
+ h.markLaunched()
+
+ switch {
+ // If we're already resolved, then we can exit early.
+ case h.IsResolved():
+ h.log.Errorf("already resolved")
+ return nil
+
+ // If this is an output on the remote party's commitment transaction,
+ // use the direct-spend path.
+ case h.isRemoteCommitOutput():
+ return h.sweepRemoteCommitOutput()
+
+ // If this is an anchor type channel, we now sweep either the
+ // second-level success tx or the output from the second-level success
+ // tx.
+ case h.isZeroFeeOutput():
+ // If the second-level success tx has already been swept, we
+ // can go ahead and sweep its output.
+ if h.outputIncubating {
+ return h.sweepSuccessTxOutput()
+ }
+
+ // Otherwise, sweep the second level tx.
+ return h.sweepSuccessTx()
+
+ // If this is a legacy channel type, the output is handled by the
+ // nursery via the Resolve so we do nothing here.
+ //
+ // TODO(yy): handle the legacy output by offering it to the sweeper.
+ default:
+ return nil
+ }
+}
diff --git a/contractcourt/htlc_success_resolver_test.go b/contractcourt/htlc_success_resolver_test.go
index c0206d8f142..fe6ee1ad0ea 100644
--- a/contractcourt/htlc_success_resolver_test.go
+++ b/contractcourt/htlc_success_resolver_test.go
@@ -5,6 +5,7 @@ import (
"fmt"
"reflect"
"testing"
+ "time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
@@ -20,6 +21,7 @@ import (
"github.com/lightningnetwork/lnd/lntest/mock"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/require"
)
var testHtlcAmt = lnwire.MilliSatoshi(200000)
@@ -39,6 +41,15 @@ type htlcResolverTestContext struct {
t *testing.T
}
+func newHtlcResolverTestContextFromReader(t *testing.T,
+ newResolver func(htlc channeldb.HTLC,
+ cfg ResolverConfig) ContractResolver) *htlcResolverTestContext {
+
+ ctx := newHtlcResolverTestContext(t, newResolver)
+
+ return ctx
+}
+
func newHtlcResolverTestContext(t *testing.T,
newResolver func(htlc channeldb.HTLC,
cfg ResolverConfig) ContractResolver) *htlcResolverTestContext {
@@ -133,8 +144,12 @@ func newHtlcResolverTestContext(t *testing.T,
func (i *htlcResolverTestContext) resolve() {
// Start resolver.
i.resolverResultChan = make(chan resolveResult, 1)
+
go func() {
- nextResolver, err := i.resolver.Resolve(false)
+ err := i.resolver.Launch()
+ require.NoError(i.t, err)
+
+ nextResolver, err := i.resolver.Resolve()
i.resolverResultChan <- resolveResult{
nextResolver: nextResolver,
err: err,
@@ -192,6 +207,7 @@ func TestHtlcSuccessSingleStage(t *testing.T) {
// sweeper.
details := &chainntnfs.SpendDetail{
SpendingTx: sweepTx,
+ SpentOutPoint: &htlcOutpoint,
SpenderTxHash: &sweepTxid,
}
ctx.notifier.SpendChan <- details
@@ -215,8 +231,8 @@ func TestHtlcSuccessSingleStage(t *testing.T) {
)
}
-// TestSecondStageResolution tests successful sweep of a second stage htlc
-// claim, going through the Nursery.
+// TestHtlcSuccessSecondStageResolution tests successful sweep of a second
+// stage htlc claim, going through the Nursery.
func TestHtlcSuccessSecondStageResolution(t *testing.T) {
commitOutpoint := wire.OutPoint{Index: 2}
htlcOutpoint := wire.OutPoint{Index: 3}
@@ -279,6 +295,7 @@ func TestHtlcSuccessSecondStageResolution(t *testing.T) {
ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
SpendingTx: sweepTx,
+ SpentOutPoint: &htlcOutpoint,
SpenderTxHash: &sweepHash,
}
@@ -302,6 +319,8 @@ func TestHtlcSuccessSecondStageResolution(t *testing.T) {
// TestHtlcSuccessSecondStageResolutionSweeper test that a resolver with
// non-nil SignDetails will offer the second-level transaction to the sweeper
// for re-signing.
+//
+//nolint:ll
func TestHtlcSuccessSecondStageResolutionSweeper(t *testing.T) {
commitOutpoint := wire.OutPoint{Index: 2}
htlcOutpoint := wire.OutPoint{Index: 3}
@@ -399,7 +418,20 @@ func TestHtlcSuccessSecondStageResolutionSweeper(t *testing.T) {
_ bool) error {
resolver := ctx.resolver.(*htlcSuccessResolver)
- inp := <-resolver.Sweeper.(*mockSweeper).sweptInputs
+
+ var (
+ inp input.Input
+ ok bool
+ )
+
+ select {
+ case inp, ok = <-resolver.Sweeper.(*mockSweeper).sweptInputs:
+ require.True(t, ok)
+
+ case <-time.After(1 * time.Second):
+ t.Fatal("expected input to be swept")
+ }
+
op := inp.OutPoint()
if op != commitOutpoint {
return fmt.Errorf("outpoint %v swept, "+
@@ -412,6 +444,7 @@ func TestHtlcSuccessSecondStageResolutionSweeper(t *testing.T) {
SpenderTxHash: &reSignedHash,
SpenderInputIndex: 1,
SpendingHeight: 10,
+ SpentOutPoint: &commitOutpoint,
}
return nil
},
@@ -434,17 +467,37 @@ func TestHtlcSuccessSecondStageResolutionSweeper(t *testing.T) {
SpenderTxHash: &reSignedHash,
SpenderInputIndex: 1,
SpendingHeight: 10,
+ SpentOutPoint: &commitOutpoint,
}
}
- ctx.notifier.EpochChan <- &chainntnfs.BlockEpoch{
- Height: 13,
- }
-
// We expect it to sweep the second-level
// transaction we notfied about above.
resolver := ctx.resolver.(*htlcSuccessResolver)
- inp := <-resolver.Sweeper.(*mockSweeper).sweptInputs
+
+ // Mock `waitForSpend` to return the commit
+ // spend.
+ ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
+ SpendingTx: reSignedSuccessTx,
+ SpenderTxHash: &reSignedHash,
+ SpenderInputIndex: 1,
+ SpendingHeight: 10,
+ SpentOutPoint: &commitOutpoint,
+ }
+
+ var (
+ inp input.Input
+ ok bool
+ )
+
+ select {
+ case inp, ok = <-resolver.Sweeper.(*mockSweeper).sweptInputs:
+ require.True(t, ok)
+
+ case <-time.After(1 * time.Second):
+ t.Fatal("expected input to be swept")
+ }
+
op := inp.OutPoint()
exp := wire.OutPoint{
Hash: reSignedHash,
@@ -461,6 +514,7 @@ func TestHtlcSuccessSecondStageResolutionSweeper(t *testing.T) {
SpendingTx: sweepTx,
SpenderTxHash: &sweepHash,
SpendingHeight: 14,
+ SpentOutPoint: &op,
}
return nil
@@ -508,11 +562,14 @@ func testHtlcSuccess(t *testing.T, resolution lnwallet.IncomingHtlcResolution,
// for the next portion of the test.
ctx := newHtlcResolverTestContext(t,
func(htlc channeldb.HTLC, cfg ResolverConfig) ContractResolver {
- return &htlcSuccessResolver{
+ r := &htlcSuccessResolver{
contractResolverKit: *newContractResolverKit(cfg),
htlc: htlc,
htlcResolution: resolution,
}
+ r.initLogger("htlcSuccessResolver")
+
+ return r
},
)
@@ -562,11 +619,11 @@ func runFromCheckpoint(t *testing.T, ctx *htlcResolverTestContext,
var resolved, incubating bool
if h, ok := resolver.(*htlcSuccessResolver); ok {
- resolved = h.resolved
+ resolved = h.resolved.Load()
incubating = h.outputIncubating
}
if h, ok := resolver.(*htlcTimeoutResolver); ok {
- resolved = h.resolved
+ resolved = h.resolved.Load()
incubating = h.outputIncubating
}
@@ -610,7 +667,12 @@ func runFromCheckpoint(t *testing.T, ctx *htlcResolverTestContext,
checkpointedState = append(checkpointedState, b.Bytes())
nextCheckpoint++
- checkpointChan <- struct{}{}
+ select {
+ case checkpointChan <- struct{}{}:
+ case <-time.After(1 * time.Second):
+ t.Fatal("checkpoint timeout")
+ }
+
return nil
}
@@ -621,6 +683,8 @@ func runFromCheckpoint(t *testing.T, ctx *htlcResolverTestContext,
// preCheckpoint logic if needed.
resumed := true
for i, cp := range expectedCheckpoints {
+ t.Logf("Running checkpoint %d", i)
+
if cp.preCheckpoint != nil {
if err := cp.preCheckpoint(ctx, resumed); err != nil {
t.Fatalf("failure at stage %d: %v", i, err)
@@ -629,15 +693,15 @@ func runFromCheckpoint(t *testing.T, ctx *htlcResolverTestContext,
resumed = false
// Wait for the resolver to have checkpointed its state.
- <-checkpointChan
+ select {
+ case <-checkpointChan:
+ case <-time.After(1 * time.Second):
+ t.Fatalf("resolver did not checkpoint at stage %d", i)
+ }
}
// Wait for the resolver to fully complete.
ctx.waitForResult()
- if nextCheckpoint < len(expectedCheckpoints) {
- t.Fatalf("not all checkpoints hit")
- }
-
return checkpointedState
}
diff --git a/contractcourt/htlc_timeout_resolver.go b/contractcourt/htlc_timeout_resolver.go
index 545e7c61353..872a3da92b5 100644
--- a/contractcourt/htlc_timeout_resolver.go
+++ b/contractcourt/htlc_timeout_resolver.go
@@ -7,6 +7,7 @@ import (
"sync"
"github.com/btcsuite/btcd/btcutil"
+ "github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/davecgh/go-spew/spew"
@@ -37,9 +38,6 @@ type htlcTimeoutResolver struct {
// incubator (utxo nursery).
outputIncubating bool
- // resolved reflects if the contract has been fully resolved or not.
- resolved bool
-
// broadcastHeight is the height that the original contract was
// broadcast to the main-chain at. We'll use this value to bound any
// historical queries to the chain for spends/confirmations.
@@ -82,6 +80,7 @@ func newTimeoutResolver(res lnwallet.OutgoingHtlcResolution,
}
h.initReport()
+ h.initLogger(fmt.Sprintf("%T(%v)", h, h.outpoint()))
return h
}
@@ -93,23 +92,25 @@ func (h *htlcTimeoutResolver) isTaproot() bool {
)
}
-// ResolverKey returns an identifier which should be globally unique for this
-// particular resolver within the chain the original contract resides within.
-//
-// NOTE: Part of the ContractResolver interface.
-func (h *htlcTimeoutResolver) ResolverKey() []byte {
+// outpoint returns the outpoint of the HTLC output we're attempting to sweep.
+func (h *htlcTimeoutResolver) outpoint() wire.OutPoint {
// The primary key for this resolver will be the outpoint of the HTLC
// on the commitment transaction itself. If this is our commitment,
// then the output can be found within the signed timeout tx,
// otherwise, it's just the ClaimOutpoint.
- var op wire.OutPoint
if h.htlcResolution.SignedTimeoutTx != nil {
- op = h.htlcResolution.SignedTimeoutTx.TxIn[0].PreviousOutPoint
- } else {
- op = h.htlcResolution.ClaimOutpoint
+ return h.htlcResolution.SignedTimeoutTx.TxIn[0].PreviousOutPoint
}
- key := newResolverID(op)
+ return h.htlcResolution.ClaimOutpoint
+}
+
+// ResolverKey returns an identifier which should be globally unique for this
+// particular resolver within the chain the original contract resides within.
+//
+// NOTE: Part of the ContractResolver interface.
+func (h *htlcTimeoutResolver) ResolverKey() []byte {
+ key := newResolverID(h.outpoint())
return key[:]
}
@@ -157,7 +158,7 @@ const (
// by the remote party. It'll extract the preimage, add it to the global cache,
// and finally send the appropriate clean up message.
func (h *htlcTimeoutResolver) claimCleanUp(
- commitSpend *chainntnfs.SpendDetail) (ContractResolver, error) {
+ commitSpend *chainntnfs.SpendDetail) error {
// Depending on if this is our commitment or not, then we'll be looking
// for a different witness pattern.
@@ -192,7 +193,7 @@ func (h *htlcTimeoutResolver) claimCleanUp(
// element, then we're actually on the losing side of a breach
// attempt...
case h.isTaproot() && len(spendingInput.Witness) == 1:
- return nil, fmt.Errorf("breach attempt failed")
+ return fmt.Errorf("breach attempt failed")
// Otherwise, they'll be spending directly from our commitment output.
// In which case the witness stack looks like:
@@ -209,8 +210,8 @@ func (h *htlcTimeoutResolver) claimCleanUp(
preimage, err := lntypes.MakePreimage(preimageBytes)
if err != nil {
- return nil, fmt.Errorf("unable to create pre-image from "+
- "witness: %v", err)
+ return fmt.Errorf("unable to create pre-image from witness: %w",
+ err)
}
log.Infof("%T(%v): extracting preimage=%v from on-chain "+
@@ -232,9 +233,9 @@ func (h *htlcTimeoutResolver) claimCleanUp(
HtlcIndex: h.htlc.HtlcIndex,
PreImage: &pre,
}); err != nil {
- return nil, err
+ return err
}
- h.resolved = true
+ h.markResolved()
// Checkpoint our resolver with a report which reflects the preimage
// claim by the remote party.
@@ -247,7 +248,7 @@ func (h *htlcTimeoutResolver) claimCleanUp(
SpendTxID: commitSpend.SpenderTxHash,
}
- return nil, h.Checkpoint(h, report)
+ return h.Checkpoint(h, report)
}
// chainDetailsToWatch returns the output and script which we use to watch for
@@ -418,70 +419,33 @@ func checkSizeAndIndex(witness wire.TxWitness, size, index int) bool {
// see a direct sweep via the timeout clause.
//
// NOTE: Part of the ContractResolver interface.
-func (h *htlcTimeoutResolver) Resolve(
- immediate bool) (ContractResolver, error) {
-
+func (h *htlcTimeoutResolver) Resolve() (ContractResolver, error) {
// If we're already resolved, then we can exit early.
- if h.resolved {
+ if h.IsResolved() {
+ h.log.Errorf("already resolved")
return nil, nil
}
- // Start by spending the HTLC output, either by broadcasting the
- // second-level timeout transaction, or directly if this is the remote
- // commitment.
- commitSpend, err := h.spendHtlcOutput(immediate)
- if err != nil {
- return nil, err
+ // If this is an output on the remote party's commitment transaction,
+ // use the direct-spend path to sweep the htlc.
+ if h.isRemoteCommitOutput() {
+ return nil, h.resolveRemoteCommitOutput()
}
- // If the spend reveals the pre-image, then we'll enter the clean up
- // workflow to pass the pre-image back to the incoming link, add it to
- // the witness cache, and exit.
- if isPreimageSpend(
- h.isTaproot(), commitSpend,
- h.htlcResolution.SignedTimeoutTx != nil,
- ) {
-
- log.Infof("%T(%v): HTLC has been swept with pre-image by "+
- "remote party during timeout flow! Adding pre-image to "+
- "witness cache", h, h.htlc.RHash[:],
- h.htlcResolution.ClaimOutpoint)
-
- return h.claimCleanUp(commitSpend)
- }
-
- // At this point, the second-level transaction is sufficiently
- // confirmed, or a transaction directly spending the output is.
- // Therefore, we can now send back our clean up message, failing the
- // HTLC on the incoming link.
- //
- // NOTE: This can be called twice if the outgoing resolver restarts
- // before the second-stage timeout transaction is confirmed.
- log.Infof("%T(%v): resolving htlc with incoming fail msg, "+
- "fully confirmed", h, h.htlcResolution.ClaimOutpoint)
-
- failureMsg := &lnwire.FailPermanentChannelFailure{}
- err = h.DeliverResolutionMsg(ResolutionMsg{
- SourceChan: h.ShortChanID,
- HtlcIndex: h.htlc.HtlcIndex,
- Failure: failureMsg,
- })
- if err != nil {
- return nil, err
+ // If this is a zero-fee HTLC, we now handle the spend from our
+ // commitment transaction.
+ if h.isZeroFeeOutput() {
+ return nil, h.resolveTimeoutTx()
}
- // Depending on whether this was a local or remote commit, we must
- // handle the spending transaction accordingly.
- return h.handleCommitSpend(commitSpend)
+ // If this is an output on our own commitment using pre-anchor channel
+ // type, we will let the utxo nursery handle it.
+ return nil, h.resolveSecondLevelTxLegacy()
}
-// sweepSecondLevelTx sends a second level timeout transaction to the sweeper.
+// sweepTimeoutTx sends a second level timeout transaction to the sweeper.
// This transaction uses the SINLGE|ANYONECANPAY flag.
-func (h *htlcTimeoutResolver) sweepSecondLevelTx(immediate bool) error {
- log.Infof("%T(%x): offering second-layer timeout tx to sweeper: %v",
- h, h.htlc.RHash[:],
- spew.Sdump(h.htlcResolution.SignedTimeoutTx))
-
+func (h *htlcTimeoutResolver) sweepTimeoutTx() error {
var inp input.Input
if h.isTaproot() {
inp = lnutils.Ptr(input.MakeHtlcSecondLevelTimeoutTaprootInput(
@@ -512,33 +476,17 @@ func (h *htlcTimeoutResolver) sweepSecondLevelTx(immediate bool) error {
btcutil.Amount(inp.SignDesc().Output.Value), 2, 0,
)
+ h.log.Infof("offering 2nd-level HTLC timeout tx to sweeper "+
+ "with deadline=%v, budget=%v", h.incomingHTLCExpiryHeight,
+ budget)
+
// For an outgoing HTLC, it must be swept before the RefundTimeout of
// its incoming HTLC is reached.
- //
- // TODO(yy): we may end up mixing inputs with different time locks.
- // Suppose we have two outgoing HTLCs,
- // - HTLC1: nLocktime is 800000, CLTV delta is 80.
- // - HTLC2: nLocktime is 800001, CLTV delta is 79.
- // This means they would both have an incoming HTLC that expires at
- // 800080, hence they share the same deadline but different locktimes.
- // However, with current design, when we are at block 800000, HTLC1 is
- // offered to the sweeper. When block 800001 is reached, HTLC1's
- // sweeping process is already started, while HTLC2 is being offered to
- // the sweeper, so they won't be mixed. This can become an issue tho,
- // if we decide to sweep per X blocks. Or the contractcourt sees the
- // block first while the sweeper is only aware of the last block. To
- // properly fix it, we need `blockbeat` to make sure subsystems are in
- // sync.
- log.Infof("%T(%x): offering second-level HTLC timeout tx to sweeper "+
- "with deadline=%v, budget=%v", h, h.htlc.RHash[:],
- h.incomingHTLCExpiryHeight, budget)
-
_, err := h.Sweeper.SweepInput(
inp,
sweep.Params{
Budget: budget,
DeadlineHeight: h.incomingHTLCExpiryHeight,
- Immediate: immediate,
},
)
if err != nil {
@@ -548,12 +496,13 @@ func (h *htlcTimeoutResolver) sweepSecondLevelTx(immediate bool) error {
return err
}
-// sendSecondLevelTxLegacy sends a second level timeout transaction to the utxo
-// nursery. This transaction uses the legacy SIGHASH_ALL flag.
-func (h *htlcTimeoutResolver) sendSecondLevelTxLegacy() error {
- log.Debugf("%T(%v): incubating htlc output", h,
- h.htlcResolution.ClaimOutpoint)
+// resolveSecondLevelTxLegacy sends a second level timeout transaction to the
+// utxo nursery. This transaction uses the legacy SIGHASH_ALL flag.
+func (h *htlcTimeoutResolver) resolveSecondLevelTxLegacy() error {
+ h.log.Debug("incubating htlc output")
+ // The utxo nursery will take care of broadcasting the second-level
+ // timeout tx and sweeping its output once it confirms.
err := h.IncubateOutputs(
h.ChanPoint, fn.Some(h.htlcResolution),
fn.None[lnwallet.IncomingHtlcResolution](),
@@ -563,16 +512,14 @@ func (h *htlcTimeoutResolver) sendSecondLevelTxLegacy() error {
return err
}
- h.outputIncubating = true
-
- return h.Checkpoint(h)
+ return h.resolveTimeoutTx()
}
// sweepDirectHtlcOutput sends the direct spend of the HTLC output to the
// sweeper. This is used when the remote party goes on chain, and we're able to
// sweep an HTLC we offered after a timeout. Only the CLTV encumbered outputs
// are resolved via this path.
-func (h *htlcTimeoutResolver) sweepDirectHtlcOutput(immediate bool) error {
+func (h *htlcTimeoutResolver) sweepDirectHtlcOutput() error {
var htlcWitnessType input.StandardWitnessType
if h.isTaproot() {
htlcWitnessType = input.TaprootHtlcOfferedRemoteTimeout
@@ -612,7 +559,6 @@ func (h *htlcTimeoutResolver) sweepDirectHtlcOutput(immediate bool) error {
// This is an outgoing HTLC, so we want to make sure
// that we sweep it before the incoming HTLC expires.
DeadlineHeight: h.incomingHTLCExpiryHeight,
- Immediate: immediate,
},
)
if err != nil {
@@ -622,53 +568,6 @@ func (h *htlcTimeoutResolver) sweepDirectHtlcOutput(immediate bool) error {
return nil
}
-// spendHtlcOutput handles the initial spend of an HTLC output via the timeout
-// clause. If this is our local commitment, the second-level timeout TX will be
-// used to spend the output into the next stage. If this is the remote
-// commitment, the output will be swept directly without the timeout
-// transaction.
-func (h *htlcTimeoutResolver) spendHtlcOutput(
- immediate bool) (*chainntnfs.SpendDetail, error) {
-
- switch {
- // If we have non-nil SignDetails, this means that have a 2nd level
- // HTLC transaction that is signed using sighash SINGLE|ANYONECANPAY
- // (the case for anchor type channels). In this case we can re-sign it
- // and attach fees at will. We let the sweeper handle this job.
- case h.htlcResolution.SignDetails != nil && !h.outputIncubating:
- if err := h.sweepSecondLevelTx(immediate); err != nil {
- log.Errorf("Sending timeout tx to sweeper: %v", err)
-
- return nil, err
- }
-
- // If this is a remote commitment there's no second level timeout txn,
- // and we can just send this directly to the sweeper.
- case h.htlcResolution.SignedTimeoutTx == nil && !h.outputIncubating:
- if err := h.sweepDirectHtlcOutput(immediate); err != nil {
- log.Errorf("Sending direct spend to sweeper: %v", err)
-
- return nil, err
- }
-
- // If we have a SignedTimeoutTx but no SignDetails, this is a local
- // commitment for a non-anchor channel, so we'll send it to the utxo
- // nursery.
- case h.htlcResolution.SignDetails == nil && !h.outputIncubating:
- if err := h.sendSecondLevelTxLegacy(); err != nil {
- log.Errorf("Sending timeout tx to nursery: %v", err)
-
- return nil, err
- }
- }
-
- // Now that we've handed off the HTLC to the nursery or sweeper, we'll
- // watch for a spend of the output, and make our next move off of that.
- // Depending on if this is our commitment, or the remote party's
- // commitment, we'll be watching a different outpoint and script.
- return h.watchHtlcSpend()
-}
-
// watchHtlcSpend watches for a spend of the HTLC output. For neutrino backend,
// it will check blocks for the confirmed spend. For btcd and bitcoind, it will
// check both the mempool and the blocks.
@@ -697,9 +596,6 @@ func (h *htlcTimeoutResolver) watchHtlcSpend() (*chainntnfs.SpendDetail,
func (h *htlcTimeoutResolver) waitForConfirmedSpend(op *wire.OutPoint,
pkScript []byte) (*chainntnfs.SpendDetail, error) {
- log.Infof("%T(%v): waiting for spent of HTLC output %v to be "+
- "fully confirmed", h, h.htlcResolution.ClaimOutpoint, op)
-
// We'll block here until either we exit, or the HTLC output on the
// commitment transaction has been spent.
spend, err := waitForSpend(
@@ -709,239 +605,18 @@ func (h *htlcTimeoutResolver) waitForConfirmedSpend(op *wire.OutPoint,
return nil, err
}
- // Once confirmed, persist the state on disk.
- if err := h.checkPointSecondLevelTx(); err != nil {
- return nil, err
- }
-
return spend, err
}
-// checkPointSecondLevelTx persists the state of a second level HTLC tx to disk
-// if it's published by the sweeper.
-func (h *htlcTimeoutResolver) checkPointSecondLevelTx() error {
- // If this was the second level transaction published by the sweeper,
- // we can checkpoint the resolver now that it's confirmed.
- if h.htlcResolution.SignDetails != nil && !h.outputIncubating {
- h.outputIncubating = true
- if err := h.Checkpoint(h); err != nil {
- log.Errorf("unable to Checkpoint: %v", err)
- return err
- }
- }
-
- return nil
-}
-
-// handleCommitSpend handles the spend of the HTLC output on the commitment
-// transaction. If this was our local commitment, the spend will be he
-// confirmed second-level timeout transaction, and we'll sweep that into our
-// wallet. If the was a remote commitment, the resolver will resolve
-// immetiately.
-func (h *htlcTimeoutResolver) handleCommitSpend(
- commitSpend *chainntnfs.SpendDetail) (ContractResolver, error) {
-
- var (
- // claimOutpoint will be the outpoint of the second level
- // transaction, or on the remote commitment directly. It will
- // start out as set in the resolution, but we'll update it if
- // the second-level goes through the sweeper and changes its
- // txid.
- claimOutpoint = h.htlcResolution.ClaimOutpoint
-
- // spendTxID will be the ultimate spend of the claimOutpoint.
- // We set it to the commit spend for now, as this is the
- // ultimate spend in case this is a remote commitment. If we go
- // through the second-level transaction, we'll update this
- // accordingly.
- spendTxID = commitSpend.SpenderTxHash
-
- reports []*channeldb.ResolverReport
- )
-
- switch {
-
- // If we swept an HTLC directly off the remote party's commitment
- // transaction, then we can exit here as there's no second level sweep
- // to do.
- case h.htlcResolution.SignedTimeoutTx == nil:
- break
-
- // If the sweeper is handling the second level transaction, wait for
- // the CSV and possible CLTV lock to expire, before sweeping the output
- // on the second-level.
- case h.htlcResolution.SignDetails != nil:
- waitHeight := h.deriveWaitHeight(
- h.htlcResolution.CsvDelay, commitSpend,
- )
-
- h.reportLock.Lock()
- h.currentReport.Stage = 2
- h.currentReport.MaturityHeight = waitHeight
- h.reportLock.Unlock()
-
- if h.hasCLTV() {
- log.Infof("%T(%x): waiting for CSV and CLTV lock to "+
- "expire at height %v", h, h.htlc.RHash[:],
- waitHeight)
- } else {
- log.Infof("%T(%x): waiting for CSV lock to expire at "+
- "height %v", h, h.htlc.RHash[:], waitHeight)
- }
-
- // Deduct one block so this input is offered to the sweeper one
- // block earlier since the sweeper will wait for one block to
- // trigger the sweeping.
- //
- // TODO(yy): this is done so the outputs can be aggregated
- // properly. Suppose CSV locks of five 2nd-level outputs all
- // expire at height 840000, there is a race in block digestion
- // between contractcourt and sweeper:
- // - G1: block 840000 received in contractcourt, it now offers
- // the outputs to the sweeper.
- // - G2: block 840000 received in sweeper, it now starts to
- // sweep the received outputs - there's no guarantee all
- // fives have been received.
- // To solve this, we either offer the outputs earlier, or
- // implement `blockbeat`, and force contractcourt and sweeper
- // to consume each block sequentially.
- waitHeight--
-
- // TODO(yy): let sweeper handles the wait?
- err := waitForHeight(waitHeight, h.Notifier, h.quit)
- if err != nil {
- return nil, err
- }
-
- // We'll use this input index to determine the second-level
- // output index on the transaction, as the signatures requires
- // the indexes to be the same. We don't look for the
- // second-level output script directly, as there might be more
- // than one HTLC output to the same pkScript.
- op := &wire.OutPoint{
- Hash: *commitSpend.SpenderTxHash,
- Index: commitSpend.SpenderInputIndex,
- }
-
- var csvWitnessType input.StandardWitnessType
- if h.isTaproot() {
- //nolint:ll
- csvWitnessType = input.TaprootHtlcOfferedTimeoutSecondLevel
- } else {
- csvWitnessType = input.HtlcOfferedTimeoutSecondLevel
- }
-
- // Let the sweeper sweep the second-level output now that the
- // CSV/CLTV locks have expired.
- inp := h.makeSweepInput(
- op, csvWitnessType,
- input.LeaseHtlcOfferedTimeoutSecondLevel,
- &h.htlcResolution.SweepSignDesc,
- h.htlcResolution.CsvDelay,
- uint32(commitSpend.SpendingHeight), h.htlc.RHash,
- h.htlcResolution.ResolutionBlob,
- )
-
- // Calculate the budget for this sweep.
- budget := calculateBudget(
- btcutil.Amount(inp.SignDesc().Output.Value),
- h.Budget.NoDeadlineHTLCRatio,
- h.Budget.NoDeadlineHTLC,
- )
-
- log.Infof("%T(%x): offering second-level timeout tx output to "+
- "sweeper with no deadline and budget=%v at height=%v",
- h, h.htlc.RHash[:], budget, waitHeight)
-
- _, err = h.Sweeper.SweepInput(
- inp,
- sweep.Params{
- Budget: budget,
-
- // For second level success tx, there's no rush
- // to get it confirmed, so we use a nil
- // deadline.
- DeadlineHeight: fn.None[int32](),
- },
- )
- if err != nil {
- return nil, err
- }
-
- // Update the claim outpoint to point to the second-level
- // transaction created by the sweeper.
- claimOutpoint = *op
- fallthrough
-
- // Finally, if this was an output on our commitment transaction, we'll
- // wait for the second-level HTLC output to be spent, and for that
- // transaction itself to confirm.
- case h.htlcResolution.SignedTimeoutTx != nil:
- log.Infof("%T(%v): waiting for nursery/sweeper to spend CSV "+
- "delayed output", h, claimOutpoint)
-
- sweepTx, err := waitForSpend(
- &claimOutpoint,
- h.htlcResolution.SweepSignDesc.Output.PkScript,
- h.broadcastHeight, h.Notifier, h.quit,
- )
- if err != nil {
- return nil, err
- }
-
- // Update the spend txid to the hash of the sweep transaction.
- spendTxID = sweepTx.SpenderTxHash
-
- // Once our sweep of the timeout tx has confirmed, we add a
- // resolution for our timeoutTx tx first stage transaction.
- timeoutTx := commitSpend.SpendingTx
- index := commitSpend.SpenderInputIndex
- spendHash := commitSpend.SpenderTxHash
-
- reports = append(reports, &channeldb.ResolverReport{
- OutPoint: timeoutTx.TxIn[index].PreviousOutPoint,
- Amount: h.htlc.Amt.ToSatoshis(),
- ResolverType: channeldb.ResolverTypeOutgoingHtlc,
- ResolverOutcome: channeldb.ResolverOutcomeFirstStage,
- SpendTxID: spendHash,
- })
- }
-
- // With the clean up message sent, we'll now mark the contract
- // resolved, update the recovered balance, record the timeout and the
- // sweep txid on disk, and wait.
- h.resolved = true
- h.reportLock.Lock()
- h.currentReport.RecoveredBalance = h.currentReport.LimboBalance
- h.currentReport.LimboBalance = 0
- h.reportLock.Unlock()
-
- amt := btcutil.Amount(h.htlcResolution.SweepSignDesc.Output.Value)
- reports = append(reports, &channeldb.ResolverReport{
- OutPoint: claimOutpoint,
- Amount: amt,
- ResolverType: channeldb.ResolverTypeOutgoingHtlc,
- ResolverOutcome: channeldb.ResolverOutcomeTimeout,
- SpendTxID: spendTxID,
- })
-
- return nil, h.Checkpoint(h, reports...)
-}
-
// Stop signals the resolver to cancel any current resolution processes, and
// suspend.
//
// NOTE: Part of the ContractResolver interface.
func (h *htlcTimeoutResolver) Stop() {
- close(h.quit)
-}
+ h.log.Debugf("stopping...")
+ defer h.log.Debugf("stopped")
-// IsResolved returns true if the stored state in the resolve is fully
-// resolved. In this case the target output can be forgotten.
-//
-// NOTE: Part of the ContractResolver interface.
-func (h *htlcTimeoutResolver) IsResolved() bool {
- return h.resolved
+ close(h.quit)
}
// report returns a report on the resolution state of the contract.
@@ -1003,7 +678,7 @@ func (h *htlcTimeoutResolver) Encode(w io.Writer) error {
if err := binary.Write(w, endian, h.outputIncubating); err != nil {
return err
}
- if err := binary.Write(w, endian, h.resolved); err != nil {
+ if err := binary.Write(w, endian, h.IsResolved()); err != nil {
return err
}
if err := binary.Write(w, endian, h.broadcastHeight); err != nil {
@@ -1044,9 +719,15 @@ func newTimeoutResolverFromReader(r io.Reader, resCfg ResolverConfig) (
if err := binary.Read(r, endian, &h.outputIncubating); err != nil {
return nil, err
}
- if err := binary.Read(r, endian, &h.resolved); err != nil {
+
+ var resolved bool
+ if err := binary.Read(r, endian, &resolved); err != nil {
return nil, err
}
+ if resolved {
+ h.markResolved()
+ }
+
if err := binary.Read(r, endian, &h.broadcastHeight); err != nil {
return nil, err
}
@@ -1066,6 +747,7 @@ func newTimeoutResolverFromReader(r io.Reader, resCfg ResolverConfig) (
}
h.initReport()
+ h.initLogger(fmt.Sprintf("%T(%v)", h, h.outpoint()))
return h, nil
}
@@ -1173,12 +855,6 @@ func (h *htlcTimeoutResolver) consumeSpendEvents(resultChan chan *spendResult,
// Create a result chan to hold the results.
result := &spendResult{}
- // hasMempoolSpend is a flag that indicates whether we have found a
- // preimage spend from the mempool. This is used to determine whether
- // to checkpoint the resolver or not when later we found the
- // corresponding block spend.
- hasMempoolSpent := false
-
// Wait for a spend event to arrive.
for {
select {
@@ -1206,23 +882,6 @@ func (h *htlcTimeoutResolver) consumeSpendEvents(resultChan chan *spendResult,
// Once confirmed, persist the state on disk if
// we haven't seen the output's spending tx in
// mempool before.
- //
- // NOTE: we don't checkpoint the resolver if
- // it's spending tx has already been found in
- // mempool - the resolver will take care of the
- // checkpoint in its `claimCleanUp`. If we do
- // checkpoint here, however, we'd create a new
- // record in db for the same htlc resolver
- // which won't be cleaned up later, resulting
- // the channel to stay in unresolved state.
- //
- // TODO(yy): when fee bumper is implemented, we
- // need to further check whether this is a
- // preimage spend. Also need to refactor here
- // to save us some indentation.
- if !hasMempoolSpent {
- result.err = h.checkPointSecondLevelTx()
- }
}
// Send the result and exit the loop.
@@ -1256,7 +915,7 @@ func (h *htlcTimeoutResolver) consumeSpendEvents(resultChan chan *spendResult,
// continue the loop.
hasPreimage := isPreimageSpend(
h.isTaproot(), spendDetail,
- h.htlcResolution.SignedTimeoutTx != nil,
+ !h.isRemoteCommitOutput(),
)
if !hasPreimage {
log.Debugf("HTLC output %s spent doesn't "+
@@ -1269,10 +928,6 @@ func (h *htlcTimeoutResolver) consumeSpendEvents(resultChan chan *spendResult,
result.spend = spendDetail
resultChan <- result
- // Set the hasMempoolSpent flag to true so we won't
- // checkpoint the resolver again in db.
- hasMempoolSpent = true
-
continue
// If the resolver exits, we exit the goroutine.
@@ -1284,3 +939,379 @@ func (h *htlcTimeoutResolver) consumeSpendEvents(resultChan chan *spendResult,
}
}
}
+
+// isRemoteCommitOutput returns a bool to indicate whether the htlc output is
+// on the remote commitment.
+func (h *htlcTimeoutResolver) isRemoteCommitOutput() bool {
+ // If we don't have a timeout transaction, then this means that this is
+ // an output on the remote party's commitment transaction.
+ return h.htlcResolution.SignedTimeoutTx == nil
+}
+
+// isZeroFeeOutput returns a boolean indicating whether the htlc output is from
+// a anchor-enabled channel, which uses the sighash SINGLE|ANYONECANPAY.
+func (h *htlcTimeoutResolver) isZeroFeeOutput() bool {
+ // If we have non-nil SignDetails, this means it has a 2nd level HTLC
+ // transaction that is signed using sighash SINGLE|ANYONECANPAY (the
+ // case for anchor type channels). In this case we can re-sign it and
+ // attach fees at will.
+ return h.htlcResolution.SignedTimeoutTx != nil &&
+ h.htlcResolution.SignDetails != nil
+}
+
+// waitHtlcSpendAndCheckPreimage waits for the htlc output to be spent and
+// checks whether the spending reveals the preimage. If the preimage is found,
+// it will be added to the preimage beacon to settle the incoming link, and a
+// nil spend details will be returned. Otherwise, the spend details will be
+// returned, indicating this is a non-preimage spend.
+func (h *htlcTimeoutResolver) waitHtlcSpendAndCheckPreimage() (
+ *chainntnfs.SpendDetail, error) {
+
+ // Wait for the htlc output to be spent, which can happen in one of the
+ // paths,
+ // 1. The remote party spends the htlc output using the preimage.
+ // 2. The local party spends the htlc timeout tx from the local
+ // commitment.
+ // 3. The local party spends the htlc output directlt from the remote
+ // commitment.
+ spend, err := h.watchHtlcSpend()
+ if err != nil {
+ return nil, err
+ }
+
+ // If the spend reveals the pre-image, then we'll enter the clean up
+ // workflow to pass the preimage back to the incoming link, add it to
+ // the witness cache, and exit.
+ if isPreimageSpend(h.isTaproot(), spend, !h.isRemoteCommitOutput()) {
+ return nil, h.claimCleanUp(spend)
+ }
+
+ return spend, nil
+}
+
+// sweepTimeoutTxOutput attempts to sweep the output of the second level
+// timeout tx.
+func (h *htlcTimeoutResolver) sweepTimeoutTxOutput() error {
+ h.log.Debugf("sweeping output %v from 2nd-level HTLC timeout tx",
+ h.htlcResolution.ClaimOutpoint)
+
+ // This should be non-blocking as we will only attempt to sweep the
+ // output when the second level tx has already been confirmed. In other
+ // words, waitHtlcSpendAndCheckPreimage will return immediately.
+ commitSpend, err := h.waitHtlcSpendAndCheckPreimage()
+ if err != nil {
+ return err
+ }
+
+ // Exit early if the spend is nil, as this means it's a remote spend
+ // using the preimage path, which is handled in claimCleanUp.
+ if commitSpend == nil {
+ h.log.Infof("preimage spend detected, skipping 2nd-level " +
+ "HTLC output sweep")
+
+ return nil
+ }
+
+ waitHeight := h.deriveWaitHeight(h.htlcResolution.CsvDelay, commitSpend)
+
+ // Now that the sweeper has broadcasted the second-level transaction,
+ // it has confirmed, and we have checkpointed our state, we'll sweep
+ // the second level output. We report the resolver has moved the next
+ // stage.
+ h.reportLock.Lock()
+ h.currentReport.Stage = 2
+ h.currentReport.MaturityHeight = waitHeight
+ h.reportLock.Unlock()
+
+ if h.hasCLTV() {
+ h.log.Infof("waiting for CSV and CLTV lock to expire at "+
+ "height %v", waitHeight)
+ } else {
+ h.log.Infof("waiting for CSV lock to expire at height %v",
+ waitHeight)
+ }
+
+ // We'll use this input index to determine the second-level output
+ // index on the transaction, as the signatures requires the indexes to
+ // be the same. We don't look for the second-level output script
+ // directly, as there might be more than one HTLC output to the same
+ // pkScript.
+ op := &wire.OutPoint{
+ Hash: *commitSpend.SpenderTxHash,
+ Index: commitSpend.SpenderInputIndex,
+ }
+
+ var witType input.StandardWitnessType
+ if h.isTaproot() {
+ witType = input.TaprootHtlcOfferedTimeoutSecondLevel
+ } else {
+ witType = input.HtlcOfferedTimeoutSecondLevel
+ }
+
+ // Let the sweeper sweep the second-level output now that the CSV/CLTV
+ // locks have expired.
+ inp := h.makeSweepInput(
+ op, witType,
+ input.LeaseHtlcOfferedTimeoutSecondLevel,
+ &h.htlcResolution.SweepSignDesc,
+ h.htlcResolution.CsvDelay, uint32(commitSpend.SpendingHeight),
+ h.htlc.RHash, h.htlcResolution.ResolutionBlob,
+ )
+
+ // Calculate the budget for this sweep.
+ budget := calculateBudget(
+ btcutil.Amount(inp.SignDesc().Output.Value),
+ h.Budget.NoDeadlineHTLCRatio,
+ h.Budget.NoDeadlineHTLC,
+ )
+
+ h.log.Infof("offering output from 2nd-level timeout tx to sweeper "+
+ "with no deadline and budget=%v", budget)
+
+ // TODO(yy): use the result chan returned from SweepInput to get the
+ // confirmation status of this sweeping tx so we don't need to make
+ // anothe subscription via `RegisterSpendNtfn` for this outpoint here
+ // in the resolver.
+ _, err = h.Sweeper.SweepInput(
+ inp,
+ sweep.Params{
+ Budget: budget,
+
+ // For second level success tx, there's no rush
+ // to get it confirmed, so we use a nil
+ // deadline.
+ DeadlineHeight: fn.None[int32](),
+ },
+ )
+
+ return err
+}
+
+// checkpointStageOne creates a checkpoint for the first stage of the htlc
+// timeout transaction. This is used to ensure that the resolver can resume
+// watching for the second stage spend in case of a restart.
+func (h *htlcTimeoutResolver) checkpointStageOne(
+ spendTxid chainhash.Hash) error {
+
+ h.log.Debugf("checkpoint stage one spend of HTLC output %v, spent "+
+ "in tx %v", h.outpoint(), spendTxid)
+
+ // Now that the second-level transaction has confirmed, we checkpoint
+ // the state so we'll go to the next stage in case of restarts.
+ h.outputIncubating = true
+
+ // Create stage-one report.
+ report := &channeldb.ResolverReport{
+ OutPoint: h.outpoint(),
+ Amount: h.htlc.Amt.ToSatoshis(),
+ ResolverType: channeldb.ResolverTypeOutgoingHtlc,
+ ResolverOutcome: channeldb.ResolverOutcomeFirstStage,
+ SpendTxID: &spendTxid,
+ }
+
+ // At this point, the second-level transaction is sufficiently
+ // confirmed. We can now send back our clean up message, failing the
+ // HTLC on the incoming link.
+ failureMsg := &lnwire.FailPermanentChannelFailure{}
+ err := h.DeliverResolutionMsg(ResolutionMsg{
+ SourceChan: h.ShortChanID,
+ HtlcIndex: h.htlc.HtlcIndex,
+ Failure: failureMsg,
+ })
+ if err != nil {
+ return err
+ }
+
+ return h.Checkpoint(h, report)
+}
+
+// checkpointClaim checkpoints the timeout resolver with the reports it needs.
+func (h *htlcTimeoutResolver) checkpointClaim(
+ spendDetail *chainntnfs.SpendDetail) error {
+
+ h.log.Infof("resolving htlc with incoming fail msg, output=%v "+
+ "confirmed in tx=%v", spendDetail.SpentOutPoint,
+ spendDetail.SpenderTxHash)
+
+ // Create a resolver report for the claiming of the HTLC.
+ amt := btcutil.Amount(h.htlcResolution.SweepSignDesc.Output.Value)
+ report := &channeldb.ResolverReport{
+ OutPoint: *spendDetail.SpentOutPoint,
+ Amount: amt,
+ ResolverType: channeldb.ResolverTypeOutgoingHtlc,
+ ResolverOutcome: channeldb.ResolverOutcomeTimeout,
+ SpendTxID: spendDetail.SpenderTxHash,
+ }
+
+ // Finally, we checkpoint the resolver with our report(s).
+ h.markResolved()
+
+ return h.Checkpoint(h, report)
+}
+
+// resolveRemoteCommitOutput handles sweeping an HTLC output on the remote
+// commitment with via the timeout path. In this case we can sweep the output
+// directly, and don't have to broadcast a second-level transaction.
+func (h *htlcTimeoutResolver) resolveRemoteCommitOutput() error {
+ h.log.Debug("waiting for direct-timeout spend of the htlc to confirm")
+
+ // Wait for the direct-timeout HTLC sweep tx to confirm.
+ spend, err := h.watchHtlcSpend()
+ if err != nil {
+ return err
+ }
+
+ // If the spend reveals the preimage, then we'll enter the clean up
+ // workflow to pass the preimage back to the incoming link, add it to
+ // the witness cache, and exit.
+ if isPreimageSpend(h.isTaproot(), spend, !h.isRemoteCommitOutput()) {
+ return h.claimCleanUp(spend)
+ }
+
+ // Send the clean up msg to fail the incoming HTLC.
+ failureMsg := &lnwire.FailPermanentChannelFailure{}
+ err = h.DeliverResolutionMsg(ResolutionMsg{
+ SourceChan: h.ShortChanID,
+ HtlcIndex: h.htlc.HtlcIndex,
+ Failure: failureMsg,
+ })
+ if err != nil {
+ return err
+ }
+
+ // TODO(yy): should also update the `RecoveredBalance` and
+ // `LimboBalance` like other paths?
+
+ // Checkpoint the resolver, and write the outcome to disk.
+ return h.checkpointClaim(spend)
+}
+
+// resolveTimeoutTx waits for the sweeping tx of the second-level
+// timeout tx to confirm and offers the output from the timeout tx to the
+// sweeper.
+func (h *htlcTimeoutResolver) resolveTimeoutTx() error {
+ h.log.Debug("waiting for first-stage 2nd-level HTLC timeout tx to " +
+ "confirm")
+
+ // Wait for the second level transaction to confirm.
+ spend, err := h.watchHtlcSpend()
+ if err != nil {
+ return err
+ }
+
+ // If the spend reveals the preimage, then we'll enter the clean up
+ // workflow to pass the preimage back to the incoming link, add it to
+ // the witness cache, and exit.
+ if isPreimageSpend(h.isTaproot(), spend, !h.isRemoteCommitOutput()) {
+ return h.claimCleanUp(spend)
+ }
+
+ op := h.htlcResolution.ClaimOutpoint
+ spenderTxid := *spend.SpenderTxHash
+
+ // If the timeout tx is a re-signed tx, we will need to find the actual
+ // spent outpoint from the spending tx.
+ if h.isZeroFeeOutput() {
+ op = wire.OutPoint{
+ Hash: spenderTxid,
+ Index: spend.SpenderInputIndex,
+ }
+ }
+
+ // If the 2nd-stage sweeping has already been started, we can
+ // fast-forward to start the resolving process for the stage two
+ // output.
+ if h.outputIncubating {
+ return h.resolveTimeoutTxOutput(op)
+ }
+
+ h.log.Infof("2nd-level HTLC timeout tx=%v confirmed", spenderTxid)
+
+ // Start the process to sweep the output from the timeout tx.
+ if h.isZeroFeeOutput() {
+ err = h.sweepTimeoutTxOutput()
+ if err != nil {
+ return err
+ }
+ }
+
+ // Create a checkpoint since the timeout tx is confirmed and the sweep
+ // request has been made.
+ if err := h.checkpointStageOne(spenderTxid); err != nil {
+ return err
+ }
+
+ // Start the resolving process for the stage two output.
+ return h.resolveTimeoutTxOutput(op)
+}
+
+// resolveTimeoutTxOutput waits for the spend of the output from the 2nd-level
+// timeout tx.
+func (h *htlcTimeoutResolver) resolveTimeoutTxOutput(op wire.OutPoint) error {
+ h.log.Debugf("waiting for second-stage 2nd-level timeout tx output %v "+
+ "to be spent after csv_delay=%v", op, h.htlcResolution.CsvDelay)
+
+ spend, err := waitForSpend(
+ &op, h.htlcResolution.SweepSignDesc.Output.PkScript,
+ h.broadcastHeight, h.Notifier, h.quit,
+ )
+ if err != nil {
+ return err
+ }
+
+ h.reportLock.Lock()
+ h.currentReport.RecoveredBalance = h.currentReport.LimboBalance
+ h.currentReport.LimboBalance = 0
+ h.reportLock.Unlock()
+
+ return h.checkpointClaim(spend)
+}
+
+// Launch creates an input based on the details of the outgoing htlc resolution
+// and offers it to the sweeper.
+func (h *htlcTimeoutResolver) Launch() error {
+ if h.isLaunched() {
+ h.log.Tracef("already launched")
+ return nil
+ }
+
+ h.log.Debugf("launching resolver...")
+ h.markLaunched()
+
+ switch {
+ // If we're already resolved, then we can exit early.
+ case h.IsResolved():
+ h.log.Errorf("already resolved")
+ return nil
+
+ // If this is an output on the remote party's commitment transaction,
+ // use the direct timeout spend path.
+ //
+ // NOTE: When the outputIncubating is false, it means that the output
+ // has been offered to the utxo nursery as starting in 0.18.4, we
+ // stopped marking this flag for direct timeout spends (#9062). In that
+ // case, we will do nothing and let the utxo nursery handle it.
+ case h.isRemoteCommitOutput() && !h.outputIncubating:
+ return h.sweepDirectHtlcOutput()
+
+ // If this is an anchor type channel, we now sweep either the
+ // second-level timeout tx or the output from the second-level timeout
+ // tx.
+ case h.isZeroFeeOutput():
+ // If the second-level timeout tx has already been swept, we
+ // can go ahead and sweep its output.
+ if h.outputIncubating {
+ return h.sweepTimeoutTxOutput()
+ }
+
+ // Otherwise, sweep the second level tx.
+ return h.sweepTimeoutTx()
+
+ // If this is an output on our own commitment using pre-anchor channel
+ // type, we will let the utxo nursery handle it via Resolve.
+ //
+ // TODO(yy): handle the legacy output by offering it to the sweeper.
+ default:
+ return nil
+ }
+}
diff --git a/contractcourt/htlc_timeout_resolver_test.go b/contractcourt/htlc_timeout_resolver_test.go
index 0e4f1336c27..017d3d38861 100644
--- a/contractcourt/htlc_timeout_resolver_test.go
+++ b/contractcourt/htlc_timeout_resolver_test.go
@@ -40,7 +40,7 @@ type mockWitnessBeacon struct {
func newMockWitnessBeacon() *mockWitnessBeacon {
return &mockWitnessBeacon{
preImageUpdates: make(chan lntypes.Preimage, 1),
- newPreimages: make(chan []lntypes.Preimage),
+ newPreimages: make(chan []lntypes.Preimage, 1),
lookupPreimage: make(map[lntypes.Hash]lntypes.Preimage),
}
}
@@ -280,7 +280,7 @@ func testHtlcTimeoutResolver(t *testing.T, testCase htlcTimeoutTestCase) {
notifier := &mock.ChainNotifier{
EpochChan: make(chan *chainntnfs.BlockEpoch),
- SpendChan: make(chan *chainntnfs.SpendDetail),
+ SpendChan: make(chan *chainntnfs.SpendDetail, 1),
ConfChan: make(chan *chainntnfs.TxConfirmation),
}
@@ -321,6 +321,7 @@ func testHtlcTimeoutResolver(t *testing.T, testCase htlcTimeoutTestCase) {
return nil
},
+ HtlcNotifier: &mockHTLCNotifier{},
},
PutResolverReport: func(_ kvdb.RwTx,
_ *channeldb.ResolverReport) error {
@@ -356,6 +357,7 @@ func testHtlcTimeoutResolver(t *testing.T, testCase htlcTimeoutTestCase) {
Amt: testHtlcAmt,
},
}
+ resolver.initLogger("timeoutResolver")
var reports []*channeldb.ResolverReport
@@ -390,7 +392,12 @@ func testHtlcTimeoutResolver(t *testing.T, testCase htlcTimeoutTestCase) {
go func() {
defer wg.Done()
- _, err := resolver.Resolve(false)
+ err := resolver.Launch()
+ if err != nil {
+ resolveErr <- err
+ }
+
+ _, err = resolver.Resolve()
if err != nil {
resolveErr <- err
}
@@ -406,8 +413,7 @@ func testHtlcTimeoutResolver(t *testing.T, testCase htlcTimeoutTestCase) {
sweepChan = mockSweeper.sweptInputs
}
- // The output should be offered to either the sweeper or
- // the nursery.
+ // The output should be offered to either the sweeper or the nursery.
select {
case <-incubateChan:
case <-sweepChan:
@@ -431,6 +437,7 @@ func testHtlcTimeoutResolver(t *testing.T, testCase htlcTimeoutTestCase) {
case notifier.SpendChan <- &chainntnfs.SpendDetail{
SpendingTx: spendingTx,
SpenderTxHash: &spendTxHash,
+ SpentOutPoint: &testChanPoint2,
}:
case <-time.After(time.Second * 5):
t.Fatalf("failed to request spend ntfn")
@@ -487,6 +494,7 @@ func testHtlcTimeoutResolver(t *testing.T, testCase htlcTimeoutTestCase) {
case notifier.SpendChan <- &chainntnfs.SpendDetail{
SpendingTx: spendingTx,
SpenderTxHash: &spendTxHash,
+ SpentOutPoint: &testChanPoint2,
}:
case <-time.After(time.Second * 5):
t.Fatalf("failed to request spend ntfn")
@@ -524,7 +532,7 @@ func testHtlcTimeoutResolver(t *testing.T, testCase htlcTimeoutTestCase) {
wg.Wait()
// Finally, the resolver should be marked as resolved.
- if !resolver.resolved {
+ if !resolver.resolved.Load() {
t.Fatalf("resolver should be marked as resolved")
}
}
@@ -549,6 +557,8 @@ func TestHtlcTimeoutResolver(t *testing.T) {
// TestHtlcTimeoutSingleStage tests a remote commitment confirming, and the
// local node sweeping the HTLC output directly after timeout.
+//
+//nolint:ll
func TestHtlcTimeoutSingleStage(t *testing.T) {
commitOutpoint := wire.OutPoint{Index: 3}
@@ -573,6 +583,12 @@ func TestHtlcTimeoutSingleStage(t *testing.T) {
SpendTxID: &sweepTxid,
}
+ sweepSpend := &chainntnfs.SpendDetail{
+ SpendingTx: sweepTx,
+ SpentOutPoint: &commitOutpoint,
+ SpenderTxHash: &sweepTxid,
+ }
+
checkpoints := []checkpoint{
{
// We send a confirmation the sweep tx from published
@@ -582,9 +598,10 @@ func TestHtlcTimeoutSingleStage(t *testing.T) {
// The nursery will create and publish a sweep
// tx.
- ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
- SpendingTx: sweepTx,
- SpenderTxHash: &sweepTxid,
+ select {
+ case ctx.notifier.SpendChan <- sweepSpend:
+ case <-time.After(time.Second * 5):
+ t.Fatalf("failed to send spend ntfn")
}
// The resolver should deliver a failure
@@ -620,7 +637,9 @@ func TestHtlcTimeoutSingleStage(t *testing.T) {
// TestHtlcTimeoutSecondStage tests a local commitment being confirmed, and the
// local node claiming the HTLC output using the second-level timeout tx.
-func TestHtlcTimeoutSecondStage(t *testing.T) {
+//
+//nolint:ll
+func TestHtlcTimeoutSecondStagex(t *testing.T) {
commitOutpoint := wire.OutPoint{Index: 2}
htlcOutpoint := wire.OutPoint{Index: 3}
@@ -678,23 +697,57 @@ func TestHtlcTimeoutSecondStage(t *testing.T) {
SpendTxID: &sweepHash,
}
+ timeoutSpend := &chainntnfs.SpendDetail{
+ SpendingTx: timeoutTx,
+ SpentOutPoint: &commitOutpoint,
+ SpenderTxHash: &timeoutTxid,
+ }
+
+ sweepSpend := &chainntnfs.SpendDetail{
+ SpendingTx: sweepTx,
+ SpentOutPoint: &htlcOutpoint,
+ SpenderTxHash: &sweepHash,
+ }
+
checkpoints := []checkpoint{
{
+ preCheckpoint: func(ctx *htlcResolverTestContext,
+ _ bool) error {
+
+ // Deliver spend of timeout tx.
+ ctx.notifier.SpendChan <- timeoutSpend
+
+ return nil
+ },
+
// Output should be handed off to the nursery.
incubating: true,
+ reports: []*channeldb.ResolverReport{
+ firstStage,
+ },
},
{
// We send a confirmation for our sweep tx to indicate
// that our sweep succeeded.
preCheckpoint: func(ctx *htlcResolverTestContext,
- _ bool) error {
+ resumed bool) error {
- // The nursery will publish the timeout tx.
- ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
- SpendingTx: timeoutTx,
- SpenderTxHash: &timeoutTxid,
+ // When it's reloaded from disk, we need to
+ // re-send the notification to mock the first
+ // `watchHtlcSpend`.
+ if resumed {
+ // Deliver spend of timeout tx.
+ ctx.notifier.SpendChan <- timeoutSpend
+
+ // Deliver spend of timeout tx output.
+ ctx.notifier.SpendChan <- sweepSpend
+
+ return nil
}
+ // Deliver spend of timeout tx output.
+ ctx.notifier.SpendChan <- sweepSpend
+
// The resolver should deliver a failure
// resolution message (indicating we
// successfully timed out the HTLC).
@@ -707,12 +760,6 @@ func TestHtlcTimeoutSecondStage(t *testing.T) {
t.Fatalf("resolution not sent")
}
- // Deliver spend of timeout tx.
- ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
- SpendingTx: sweepTx,
- SpenderTxHash: &sweepHash,
- }
-
return nil
},
@@ -722,7 +769,7 @@ func TestHtlcTimeoutSecondStage(t *testing.T) {
incubating: true,
resolved: true,
reports: []*channeldb.ResolverReport{
- firstStage, secondState,
+ secondState,
},
},
}
@@ -796,10 +843,6 @@ func TestHtlcTimeoutSingleStageRemoteSpend(t *testing.T) {
}
checkpoints := []checkpoint{
- {
- // Output should be handed off to the nursery.
- incubating: true,
- },
{
// We send a spend notification for a remote spend with
// the preimage.
@@ -812,6 +855,7 @@ func TestHtlcTimeoutSingleStageRemoteSpend(t *testing.T) {
// the preimage.
ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
SpendingTx: spendTx,
+ SpentOutPoint: &commitOutpoint,
SpenderTxHash: &spendTxHash,
}
@@ -847,7 +891,7 @@ func TestHtlcTimeoutSingleStageRemoteSpend(t *testing.T) {
// After the success tx has confirmed, we expect the
// checkpoint to be resolved, and with the above
// report.
- incubating: true,
+ incubating: false,
resolved: true,
reports: []*channeldb.ResolverReport{
claim,
@@ -914,6 +958,7 @@ func TestHtlcTimeoutSecondStageRemoteSpend(t *testing.T) {
ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
SpendingTx: remoteSuccessTx,
+ SpentOutPoint: &commitOutpoint,
SpenderTxHash: &successTxid,
}
@@ -967,20 +1012,15 @@ func TestHtlcTimeoutSecondStageRemoteSpend(t *testing.T) {
// TestHtlcTimeoutSecondStageSweeper tests that for anchor channels, when a
// local commitment confirms, the timeout tx is handed to the sweeper to claim
// the HTLC output.
+//
+//nolint:ll
func TestHtlcTimeoutSecondStageSweeper(t *testing.T) {
- commitOutpoint := wire.OutPoint{Index: 2}
htlcOutpoint := wire.OutPoint{Index: 3}
- sweepTx := &wire.MsgTx{
- TxIn: []*wire.TxIn{{}},
- TxOut: []*wire.TxOut{{}},
- }
- sweepHash := sweepTx.TxHash()
-
timeoutTx := &wire.MsgTx{
TxIn: []*wire.TxIn{
{
- PreviousOutPoint: commitOutpoint,
+ PreviousOutPoint: htlcOutpoint,
},
},
TxOut: []*wire.TxOut{
@@ -1027,11 +1067,16 @@ func TestHtlcTimeoutSecondStageSweeper(t *testing.T) {
},
}
reSignedHash := reSignedTimeoutTx.TxHash()
- reSignedOutPoint := wire.OutPoint{
+
+ timeoutTxOutpoint := wire.OutPoint{
Hash: reSignedHash,
Index: 1,
}
+ // Make a copy so `isPreimageSpend` can easily pass.
+ sweepTx := reSignedTimeoutTx.Copy()
+ sweepHash := sweepTx.TxHash()
+
// twoStageResolution is a resolution for a htlc on the local
// party's commitment, where the timeout tx can be re-signed.
twoStageResolution := lnwallet.OutgoingHtlcResolution{
@@ -1045,7 +1090,7 @@ func TestHtlcTimeoutSecondStageSweeper(t *testing.T) {
}
firstStage := &channeldb.ResolverReport{
- OutPoint: commitOutpoint,
+ OutPoint: htlcOutpoint,
Amount: testHtlcAmt.ToSatoshis(),
ResolverType: channeldb.ResolverTypeOutgoingHtlc,
ResolverOutcome: channeldb.ResolverOutcomeFirstStage,
@@ -1053,12 +1098,45 @@ func TestHtlcTimeoutSecondStageSweeper(t *testing.T) {
}
secondState := &channeldb.ResolverReport{
- OutPoint: reSignedOutPoint,
+ OutPoint: timeoutTxOutpoint,
Amount: btcutil.Amount(testSignDesc.Output.Value),
ResolverType: channeldb.ResolverTypeOutgoingHtlc,
ResolverOutcome: channeldb.ResolverOutcomeTimeout,
SpendTxID: &sweepHash,
}
+ // mockTimeoutTxSpend is a helper closure to mock `waitForSpend` to
+ // return the commit spend in `sweepTimeoutTxOutput`.
+ mockTimeoutTxSpend := func(ctx *htlcResolverTestContext) {
+ select {
+ case ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
+ SpendingTx: reSignedTimeoutTx,
+ SpenderInputIndex: 1,
+ SpenderTxHash: &reSignedHash,
+ SpendingHeight: 10,
+ SpentOutPoint: &htlcOutpoint,
+ }:
+
+ case <-time.After(time.Second * 1):
+ t.Fatalf("spend not sent")
+ }
+ }
+
+ // mockSweepTxSpend is a helper closure to mock `waitForSpend` to
+ // return the commit spend in `sweepTimeoutTxOutput`.
+ mockSweepTxSpend := func(ctx *htlcResolverTestContext) {
+ select {
+ case ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
+ SpendingTx: sweepTx,
+ SpenderInputIndex: 1,
+ SpenderTxHash: &sweepHash,
+ SpendingHeight: 10,
+ SpentOutPoint: &timeoutTxOutpoint,
+ }:
+
+ case <-time.After(time.Second * 1):
+ t.Fatalf("spend not sent")
+ }
+ }
checkpoints := []checkpoint{
{
@@ -1067,28 +1145,40 @@ func TestHtlcTimeoutSecondStageSweeper(t *testing.T) {
_ bool) error {
resolver := ctx.resolver.(*htlcTimeoutResolver)
- inp := <-resolver.Sweeper.(*mockSweeper).sweptInputs
+
+ var (
+ inp input.Input
+ ok bool
+ )
+
+ select {
+ case inp, ok = <-resolver.Sweeper.(*mockSweeper).sweptInputs:
+ require.True(t, ok)
+
+ case <-time.After(1 * time.Second):
+ t.Fatal("expected input to be swept")
+ }
+
op := inp.OutPoint()
- if op != commitOutpoint {
+ if op != htlcOutpoint {
return fmt.Errorf("outpoint %v swept, "+
- "expected %v", op,
- commitOutpoint)
+ "expected %v", op, htlcOutpoint)
}
- // Emulat the sweeper spending using the
- // re-signed timeout tx.
- ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
- SpendingTx: reSignedTimeoutTx,
- SpenderInputIndex: 1,
- SpenderTxHash: &reSignedHash,
- SpendingHeight: 10,
- }
+ // Mock `waitForSpend` twice, called in,
+ // - `resolveReSignedTimeoutTx`
+ // - `sweepTimeoutTxOutput`.
+ mockTimeoutTxSpend(ctx)
+ mockTimeoutTxSpend(ctx)
return nil
},
// incubating=true is used to signal that the
// second-level transaction was confirmed.
incubating: true,
+ reports: []*channeldb.ResolverReport{
+ firstStage,
+ },
},
{
// We send a confirmation for our sweep tx to indicate
@@ -1096,18 +1186,18 @@ func TestHtlcTimeoutSecondStageSweeper(t *testing.T) {
preCheckpoint: func(ctx *htlcResolverTestContext,
resumed bool) error {
- // If we are resuming from a checkpoint, we
- // expect the resolver to re-subscribe to a
- // spend, hence we must resend it.
+ // Mock `waitForSpend` to return the commit
+ // spend.
if resumed {
- ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
- SpendingTx: reSignedTimeoutTx,
- SpenderInputIndex: 1,
- SpenderTxHash: &reSignedHash,
- SpendingHeight: 10,
- }
+ mockTimeoutTxSpend(ctx)
+ mockTimeoutTxSpend(ctx)
+ mockSweepTxSpend(ctx)
+
+ return nil
}
+ mockSweepTxSpend(ctx)
+
// The resolver should deliver a failure
// resolution message (indicating we
// successfully timed out the HTLC).
@@ -1120,15 +1210,23 @@ func TestHtlcTimeoutSecondStageSweeper(t *testing.T) {
t.Fatalf("resolution not sent")
}
- // Mimic CSV lock expiring.
- ctx.notifier.EpochChan <- &chainntnfs.BlockEpoch{
- Height: 13,
- }
-
// The timeout tx output should now be given to
// the sweeper.
resolver := ctx.resolver.(*htlcTimeoutResolver)
- inp := <-resolver.Sweeper.(*mockSweeper).sweptInputs
+
+ var (
+ inp input.Input
+ ok bool
+ )
+
+ select {
+ case inp, ok = <-resolver.Sweeper.(*mockSweeper).sweptInputs:
+ require.True(t, ok)
+
+ case <-time.After(1 * time.Second):
+ t.Fatal("expected input to be swept")
+ }
+
op := inp.OutPoint()
exp := wire.OutPoint{
Hash: reSignedHash,
@@ -1138,14 +1236,6 @@ func TestHtlcTimeoutSecondStageSweeper(t *testing.T) {
return fmt.Errorf("wrong outpoint swept")
}
- // Notify about the spend, which should resolve
- // the resolver.
- ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
- SpendingTx: sweepTx,
- SpenderTxHash: &sweepHash,
- SpendingHeight: 14,
- }
-
return nil
},
@@ -1155,7 +1245,6 @@ func TestHtlcTimeoutSecondStageSweeper(t *testing.T) {
incubating: true,
resolved: true,
reports: []*channeldb.ResolverReport{
- firstStage,
secondState,
},
},
@@ -1236,33 +1325,6 @@ func TestHtlcTimeoutSecondStageSweeperRemoteSpend(t *testing.T) {
}
checkpoints := []checkpoint{
- {
- // The output should be given to the sweeper.
- preCheckpoint: func(ctx *htlcResolverTestContext,
- _ bool) error {
-
- resolver := ctx.resolver.(*htlcTimeoutResolver)
- inp := <-resolver.Sweeper.(*mockSweeper).sweptInputs
- op := inp.OutPoint()
- if op != commitOutpoint {
- return fmt.Errorf("outpoint %v swept, "+
- "expected %v", op,
- commitOutpoint)
- }
-
- // Emulate the remote sweeping the output with the preimage.
- // re-signed timeout tx.
- ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
- SpendingTx: spendTx,
- SpenderTxHash: &spendTxHash,
- }
-
- return nil
- },
- // incubating=true is used to signal that the
- // second-level transaction was confirmed.
- incubating: true,
- },
{
// We send a confirmation for our sweep tx to indicate
// that our sweep succeeded.
@@ -1277,6 +1339,7 @@ func TestHtlcTimeoutSecondStageSweeperRemoteSpend(t *testing.T) {
ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{
SpendingTx: spendTx,
SpenderTxHash: &spendTxHash,
+ SpentOutPoint: &commitOutpoint,
}
}
@@ -1314,7 +1377,7 @@ func TestHtlcTimeoutSecondStageSweeperRemoteSpend(t *testing.T) {
// After the sweep has confirmed, we expect the
// checkpoint to be resolved, and with the above
// reports.
- incubating: true,
+ incubating: false,
resolved: true,
reports: []*channeldb.ResolverReport{
claim,
@@ -1339,21 +1402,26 @@ func testHtlcTimeout(t *testing.T, resolution lnwallet.OutgoingHtlcResolution,
// for the next portion of the test.
ctx := newHtlcResolverTestContext(t,
func(htlc channeldb.HTLC, cfg ResolverConfig) ContractResolver {
- return &htlcTimeoutResolver{
+ r := &htlcTimeoutResolver{
contractResolverKit: *newContractResolverKit(cfg),
htlc: htlc,
htlcResolution: resolution,
}
+ r.initLogger("htlcTimeoutResolver")
+
+ return r
},
)
checkpointedState := runFromCheckpoint(t, ctx, checkpoints)
+ t.Log("Running resolver to completion after restart")
+
// Now, from every checkpoint created, we re-create the resolver, and
// run the test from that checkpoint.
for i := range checkpointedState {
cp := bytes.NewReader(checkpointedState[i])
- ctx := newHtlcResolverTestContext(t,
+ ctx := newHtlcResolverTestContextFromReader(t,
func(htlc channeldb.HTLC, cfg ResolverConfig) ContractResolver {
resolver, err := newTimeoutResolverFromReader(cp, cfg)
if err != nil {
@@ -1361,7 +1429,8 @@ func testHtlcTimeout(t *testing.T, resolution lnwallet.OutgoingHtlcResolution,
}
resolver.Supplement(htlc)
- resolver.htlcResolution = resolution
+ resolver.initLogger("htlcTimeoutResolver")
+
return resolver
},
)
diff --git a/contractcourt/mock_registry_test.go b/contractcourt/mock_registry_test.go
index 5bba11afcb2..0530ab51dd4 100644
--- a/contractcourt/mock_registry_test.go
+++ b/contractcourt/mock_registry_test.go
@@ -29,6 +29,11 @@ func (r *mockRegistry) NotifyExitHopHtlc(payHash lntypes.Hash,
wireCustomRecords lnwire.CustomRecords,
payload invoices.Payload) (invoices.HtlcResolution, error) {
+ // Exit early if the notification channel is nil.
+ if hodlChan == nil {
+ return r.notifyResolution, r.notifyErr
+ }
+
r.notifyChan <- notifyExitHopData{
hodlChan: hodlChan,
payHash: payHash,
diff --git a/contractcourt/utxonursery.go b/contractcourt/utxonursery.go
index a870683746e..f78be9fa494 100644
--- a/contractcourt/utxonursery.go
+++ b/contractcourt/utxonursery.go
@@ -794,7 +794,7 @@ func (u *UtxoNursery) graduateClass(classHeight uint32) error {
return err
}
- utxnLog.Infof("Attempting to graduate height=%v: num_kids=%v, "+
+ utxnLog.Debugf("Attempting to graduate height=%v: num_kids=%v, "+
"num_babies=%v", classHeight, len(kgtnOutputs), len(cribOutputs))
// Offer the outputs to the sweeper and set up notifications that will
diff --git a/docs/release-notes/release-notes-0.19.0.md b/docs/release-notes/release-notes-0.19.0.md
index 43ed7ebbcdc..f9491658f88 100644
--- a/docs/release-notes/release-notes-0.19.0.md
+++ b/docs/release-notes/release-notes-0.19.0.md
@@ -124,6 +124,18 @@
* LND updates channel.backup file at shutdown time.
+* A new subsystem `chainio` is
+ [introduced](https://github.com/lightningnetwork/lnd/pull/9315) to make sure
+ the subsystems are in sync with their current best block. Previously, when
+ resolving a force close channel, the sweeping of HTLCs may be delayed for one
+ or two blocks due to block heights not in sync in the relevant subsystems
+ (`ChainArbitrator`, `UtxoSweeper` and `TxPublisher`), causing a slight
+ inaccuracy when deciding the sweeping feerate and urgency. With `chainio`,
+ this is now fixed as these subsystems now share the same view on the best
+ block. Check
+ [here](https://github.com/lightningnetwork/lnd/blob/master/chainio/README.md)
+ to learn more.
+
## RPC Updates
* Some RPCs that previously just returned an empty response message now at least
diff --git a/htlcswitch/switch.go b/htlcswitch/switch.go
index 704887e8e12..720625f2c5a 100644
--- a/htlcswitch/switch.go
+++ b/htlcswitch/switch.go
@@ -1606,7 +1606,7 @@ out:
}
}
- log.Infof("Received outside contract resolution, "+
+ log.Debugf("Received outside contract resolution, "+
"mapping to: %v", spew.Sdump(pkt))
// We don't check the error, as the only failure we can
diff --git a/invoices/invoiceregistry.go b/invoices/invoiceregistry.go
index f5a6c6a95fc..cc76d5aefa5 100644
--- a/invoices/invoiceregistry.go
+++ b/invoices/invoiceregistry.go
@@ -1275,7 +1275,11 @@ func (i *InvoiceRegistry) notifyExitHopHtlcLocked(
invoiceToExpire = makeInvoiceExpiry(ctx.hash, invoice)
}
- i.hodlSubscribe(hodlChan, ctx.circuitKey)
+ // Subscribe to the resolution if the caller specified a
+ // notification channel.
+ if hodlChan != nil {
+ i.hodlSubscribe(hodlChan, ctx.circuitKey)
+ }
default:
panic("unknown action")
diff --git a/itest/list_on_test.go b/itest/list_on_test.go
index fe6c373855b..a192a423f44 100644
--- a/itest/list_on_test.go
+++ b/itest/list_on_test.go
@@ -13,10 +13,6 @@ var allTestCases = []*lntest.TestCase{
Name: "basic funding flow",
TestFunc: testBasicChannelFunding,
},
- {
- Name: "multi hop receiver chain claim",
- TestFunc: testMultiHopReceiverChainClaim,
- },
{
Name: "external channel funding",
TestFunc: testExternalFundingChanPoint,
@@ -153,18 +149,6 @@ var allTestCases = []*lntest.TestCase{
Name: "addpeer config",
TestFunc: testAddPeerConfig,
},
- {
- Name: "multi hop htlc local timeout",
- TestFunc: testMultiHopHtlcLocalTimeout,
- },
- {
- Name: "multi hop local force close on-chain htlc timeout",
- TestFunc: testMultiHopLocalForceCloseOnChainHtlcTimeout,
- },
- {
- Name: "multi hop remote force close on-chain htlc timeout",
- TestFunc: testMultiHopRemoteForceCloseOnChainHtlcTimeout,
- },
{
Name: "private channel update policy",
TestFunc: testUpdateChannelPolicyForPrivateChannel,
@@ -226,11 +210,15 @@ var allTestCases = []*lntest.TestCase{
TestFunc: testChannelUnsettledBalance,
},
{
- Name: "channel force closure",
- TestFunc: testChannelForceClosure,
+ Name: "channel force closure anchor",
+ TestFunc: testChannelForceClosureAnchor,
+ },
+ {
+ Name: "channel force closure simple taproot",
+ TestFunc: testChannelForceClosureSimpleTaproot,
},
{
- Name: "failing link",
+ Name: "failing channel",
TestFunc: testFailingChannel,
},
{
@@ -313,18 +301,6 @@ var allTestCases = []*lntest.TestCase{
Name: "REST API",
TestFunc: testRestAPI,
},
- {
- Name: "multi hop htlc local chain claim",
- TestFunc: testMultiHopHtlcLocalChainClaim,
- },
- {
- Name: "multi hop htlc remote chain claim",
- TestFunc: testMultiHopHtlcRemoteChainClaim,
- },
- {
- Name: "multi hop htlc aggregation",
- TestFunc: testMultiHopHtlcAggregation,
- },
{
Name: "revoked uncooperative close retribution",
TestFunc: testRevokedCloseRetribution,
@@ -574,10 +550,6 @@ var allTestCases = []*lntest.TestCase{
Name: "lookup htlc resolution",
TestFunc: testLookupHtlcResolution,
},
- {
- Name: "watchtower",
- TestFunc: testWatchtower,
- },
{
Name: "channel fundmax",
TestFunc: testChannelFundMax,
@@ -715,3 +687,9 @@ var allTestCases = []*lntest.TestCase{
TestFunc: testQuiescence,
},
}
+
+func init() {
+ // Register subtests.
+ allTestCases = append(allTestCases, multiHopForceCloseTestCases...)
+ allTestCases = append(allTestCases, watchtowerTestCases...)
+}
diff --git a/itest/lnd_channel_backup_test.go b/itest/lnd_channel_backup_test.go
index 5140de5056d..4ea5f6933ba 100644
--- a/itest/lnd_channel_backup_test.go
+++ b/itest/lnd_channel_backup_test.go
@@ -637,8 +637,8 @@ func runChanRestoreScenarioCommitTypes(ht *lntest.HarnessTest,
minerHeight := ht.CurrentHeight()
thawHeight := minerHeight + thawHeightDelta
- fundingShim, _ = deriveFundingShim(
- ht, dave, carol, crs.params.Amt, thawHeight, true, ct,
+ fundingShim, _ = ht.DeriveFundingShim(
+ dave, carol, crs.params.Amt, thawHeight, true, ct,
)
crs.params.FundingShim = fundingShim
}
@@ -1320,12 +1320,20 @@ func testDataLossProtection(ht *lntest.HarnessTest) {
// information Dave needs to sweep his funds.
require.NoError(ht, restartDave(), "unable to restart Eve")
+ // Mine a block to trigger Dave's chain watcher to process Carol's sweep
+ // tx.
+ //
+ // TODO(yy): remove this block once the blockbeat starts remembering
+ // its last processed block and can handle looking for spends in the
+ // past blocks.
+ ht.MineEmptyBlocks(1)
+
+ // Make sure Dave still has the pending force close channel.
+ ht.AssertNumPendingForceClose(dave, 1)
+
// Dave should have a pending sweep.
ht.AssertNumPendingSweeps(dave, 1)
- // Mine a block to trigger the sweep.
- ht.MineBlocks(1)
-
// Dave should sweep his funds.
ht.AssertNumTxsInMempool(1)
@@ -1482,7 +1490,6 @@ func assertTimeLockSwept(ht *lntest.HarnessTest, carol, dave *node.HarnessNode,
expectedTxes := 1
// Mine a block to trigger the sweeps.
- ht.MineBlocks(1)
ht.AssertNumTxsInMempool(expectedTxes)
// Carol should consider the channel pending force close (since she is
@@ -1512,7 +1519,7 @@ func assertTimeLockSwept(ht *lntest.HarnessTest, carol, dave *node.HarnessNode,
// The commit sweep resolver publishes the sweep tx at defaultCSV-1 and
// we already mined one block after the commitment was published, and
// one block to trigger Carol's sweeps, so take that into account.
- ht.MineEmptyBlocks(1)
+ ht.MineBlocks(2)
ht.AssertNumPendingSweeps(dave, 2)
// Mine a block to trigger the sweeps.
@@ -1615,8 +1622,6 @@ func assertDLPExecuted(ht *lntest.HarnessTest,
// output and the other for her anchor.
ht.AssertNumPendingSweeps(carol, 2)
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
ht.MineBlocksAndAssertNumTxes(1, 1)
// Now the channel should be fully closed also from Carol's POV.
@@ -1635,8 +1640,6 @@ func assertDLPExecuted(ht *lntest.HarnessTest,
// output and the other for his anchor.
ht.AssertNumPendingSweeps(dave, 2)
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
ht.MineBlocksAndAssertNumTxes(1, 1)
// Now Dave should consider the channel fully closed.
@@ -1652,10 +1655,6 @@ func assertDLPExecuted(ht *lntest.HarnessTest,
ht.AssertNumPendingSweeps(dave, 1)
}
- // Mine one block to trigger the sweeper to sweep.
- ht.MineEmptyBlocks(1)
- blocksMined++
-
// Expect one tx - the commitment sweep from Dave. For anchor
// channels, we expect the two anchor sweeping txns to be
// failed due they are uneconomical.
@@ -1673,9 +1672,6 @@ func assertDLPExecuted(ht *lntest.HarnessTest,
// commitmment was published, so take that into account.
ht.MineEmptyBlocks(int(defaultCSV - blocksMined))
- // Mine one block to trigger the sweeper to sweep.
- ht.MineEmptyBlocks(1)
-
// Carol should have two pending sweeps:
// 1. her commit output.
// 2. her anchor output, if this is anchor channel.
diff --git a/itest/lnd_channel_balance_test.go b/itest/lnd_channel_balance_test.go
index 72dd16ea34b..8ab276d2e64 100644
--- a/itest/lnd_channel_balance_test.go
+++ b/itest/lnd_channel_balance_test.go
@@ -156,7 +156,7 @@ func testChannelUnsettledBalance(ht *lntest.HarnessTest) {
TimeoutSeconds: 60,
FeeLimitMsat: noFeeLimitMsat,
}
- alice.RPC.SendPayment(req)
+ ht.SendPaymentAssertInflight(alice, req)
}()
}
diff --git a/itest/lnd_channel_force_close_test.go b/itest/lnd_channel_force_close_test.go
index 81bdfad4316..e5ef6b7cab9 100644
--- a/itest/lnd_channel_force_close_test.go
+++ b/itest/lnd_channel_force_close_test.go
@@ -3,115 +3,97 @@ package itest
import (
"bytes"
"fmt"
- "testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/go-errors/errors"
"github.com/lightningnetwork/lnd"
- "github.com/lightningnetwork/lnd/chainreg"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/lightningnetwork/lnd/lntest/node"
"github.com/lightningnetwork/lnd/lntest/wait"
- "github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/routing"
"github.com/stretchr/testify/require"
)
-// testChannelForceClosure performs a test to exercise the behavior of "force"
-// closing a channel or unilaterally broadcasting the latest local commitment
-// state on-chain. The test creates a new channel between Alice and Carol, then
-// force closes the channel after some cursory assertions. Within the test, a
-// total of 3 + n transactions will be broadcast, representing the commitment
-// transaction, a transaction sweeping the local CSV delayed output, a
-// transaction sweeping the CSV delayed 2nd-layer htlcs outputs, and n
-// htlc timeout transactions, where n is the number of payments Alice attempted
-// to send to Carol. This test includes several restarts to ensure that the
-// transaction output states are persisted throughout the forced closure
-// process.
-//
-// TODO(roasbeef): also add an unsettled HTLC before force closing.
-func testChannelForceClosure(ht *lntest.HarnessTest) {
- // We'll test the scenario for some of the commitment types, to ensure
- // outputs can be swept.
- commitTypes := []lnrpc.CommitmentType{
- lnrpc.CommitmentType_ANCHORS,
- lnrpc.CommitmentType_SIMPLE_TAPROOT,
+const pushAmt = btcutil.Amount(5e5)
+
+// testChannelForceClosureAnchor runs `runChannelForceClosureTest` with anchor
+// channels.
+func testChannelForceClosureAnchor(ht *lntest.HarnessTest) {
+ // Create a simple network: Alice -> Carol, using anchor channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ PushAmt: pushAmt,
+ CommitmentType: lnrpc.CommitmentType_ANCHORS,
}
- for _, channelType := range commitTypes {
- testName := fmt.Sprintf("committype=%v", channelType)
-
- channelType := channelType
- success := ht.Run(testName, func(t *testing.T) {
- st := ht.Subtest(t)
-
- args := lntest.NodeArgsForCommitType(channelType)
- alice := st.NewNode("Alice", args)
- defer st.Shutdown(alice)
-
- // Since we'd like to test failure scenarios with
- // outstanding htlcs, we'll introduce another node into
- // our test network: Carol.
- carolArgs := []string{"--hodl.exit-settle"}
- carolArgs = append(carolArgs, args...)
- carol := st.NewNode("Carol", carolArgs)
- defer st.Shutdown(carol)
-
- // Each time, we'll send Alice new set of coins in
- // order to fund the channel.
- st.FundCoins(btcutil.SatoshiPerBitcoin, alice)
-
- // NOTE: Alice needs 3 more UTXOs to sweep her
- // second-layer txns after a restart - after a restart
- // all the time-sensitive sweeps are swept immediately
- // without being aggregated.
- //
- // TODO(yy): remove this once the can recover its state
- // from restart.
- st.FundCoins(btcutil.SatoshiPerBitcoin, alice)
- st.FundCoins(btcutil.SatoshiPerBitcoin, alice)
- st.FundCoins(btcutil.SatoshiPerBitcoin, alice)
- st.FundCoins(btcutil.SatoshiPerBitcoin, alice)
-
- // Also give Carol some coins to allow her to sweep her
- // anchor.
- st.FundCoins(btcutil.SatoshiPerBitcoin, carol)
-
- channelForceClosureTest(st, alice, carol, channelType)
- })
- if !success {
- return
- }
+ cfg := node.CfgAnchor
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfgCarol}
+
+ runChannelForceClosureTest(ht, cfgs, openChannelParams)
+}
+
+// testChannelForceClosureSimpleTaproot runs `runChannelForceClosureTest` with
+// simple taproot channels.
+func testChannelForceClosureSimpleTaproot(ht *lntest.HarnessTest) {
+ // Create a simple network: Alice -> Carol, using simple taproot
+ // channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ PushAmt: pushAmt,
+ // If the channel is a taproot channel, then we'll need to
+ // create a private channel.
+ //
+ // TODO(roasbeef): lift after G175
+ CommitmentType: lnrpc.CommitmentType_SIMPLE_TAPROOT,
+ Private: true,
}
+
+ cfg := node.CfgSimpleTaproot
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfgCarol}
+
+ runChannelForceClosureTest(ht, cfgs, openChannelParams)
}
-func channelForceClosureTest(ht *lntest.HarnessTest,
- alice, carol *node.HarnessNode, channelType lnrpc.CommitmentType) {
+// runChannelForceClosureTest performs a test to exercise the behavior of
+// "force" closing a channel or unilaterally broadcasting the latest local
+// commitment state on-chain. The test creates a new channel between Alice and
+// Carol, then force closes the channel after some cursory assertions. Within
+// the test, a total of 3 + n transactions will be broadcast, representing the
+// commitment transaction, a transaction sweeping the local CSV delayed output,
+// a transaction sweeping the CSV delayed 2nd-layer htlcs outputs, and n htlc
+// timeout transactions, where n is the number of payments Alice attempted
+// to send to Carol. This test includes several restarts to ensure that the
+// transaction output states are persisted throughout the forced closure
+// process.
+func runChannelForceClosureTest(ht *lntest.HarnessTest,
+ cfgs [][]string, params lntest.OpenChannelParams) {
const (
- chanAmt = btcutil.Amount(10e6)
- pushAmt = btcutil.Amount(5e6)
- paymentAmt = 100000
- numInvoices = 6
+ numInvoices = 6
+ commitFeeRate = 20000
)
- const commitFeeRate = 20000
ht.SetFeeEstimate(commitFeeRate)
- // TODO(roasbeef): should check default value in config here
- // instead, or make delay a param
- defaultCLTV := uint32(chainreg.DefaultBitcoinTimeLockDelta)
-
- // We must let Alice have an open channel before she can send a node
- // announcement, so we open a channel with Carol,
- ht.ConnectNodes(alice, carol)
+ // Create a three hop network: Alice -> Carol.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, carol := nodes[0], nodes[1]
+ chanPoint := chanPoints[0]
// We need one additional UTXO for sweeping the remote anchor.
- ht.FundCoins(btcutil.SatoshiPerBitcoin, alice)
+ if ht.IsNeutrinoBackend() {
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, alice)
+ }
// Before we start, obtain Carol's current wallet balance, we'll check
// to ensure that at the end of the force closure by Alice, Carol
@@ -119,24 +101,6 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
carolBalResp := carol.RPC.WalletBalance()
carolStartingBalance := carolBalResp.ConfirmedBalance
- // If the channel is a taproot channel, then we'll need to create a
- // private channel.
- //
- // TODO(roasbeef): lift after G175
- var privateChan bool
- if channelType == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- privateChan = true
- }
-
- chanPoint := ht.OpenChannel(
- alice, carol, lntest.OpenChannelParams{
- Private: privateChan,
- Amt: chanAmt,
- PushAmt: pushAmt,
- CommitmentType: channelType,
- },
- )
-
// Send payments from Alice to Carol, since Carol is htlchodl mode, the
// htlc outputs should be left unsettled, and should be swept by the
// utxo nursery.
@@ -146,11 +110,11 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
Dest: carolPubKey,
Amt: int64(paymentAmt),
PaymentHash: ht.Random32Bytes(),
- FinalCltvDelta: chainreg.DefaultBitcoinTimeLockDelta,
+ FinalCltvDelta: finalCltvDelta,
TimeoutSeconds: 60,
FeeLimitMsat: noFeeLimitMsat,
}
- alice.RPC.SendPayment(req)
+ ht.SendPaymentAssertInflight(alice, req)
}
// Once the HTLC has cleared, all the nodes n our mini network should
@@ -163,13 +127,13 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
curHeight := int32(ht.CurrentHeight())
// Using the current height of the chain, derive the relevant heights
- // for incubating two-stage htlcs.
+ // for sweeping two-stage htlcs.
var (
startHeight = uint32(curHeight)
commCsvMaturityHeight = startHeight + 1 + defaultCSV
- htlcExpiryHeight = padCLTV(startHeight + defaultCLTV)
+ htlcExpiryHeight = padCLTV(startHeight + finalCltvDelta)
htlcCsvMaturityHeight = padCLTV(
- startHeight + defaultCLTV + 1 + defaultCSV,
+ startHeight + finalCltvDelta + 1 + defaultCSV,
)
)
@@ -200,21 +164,15 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
)
// The several restarts in this test are intended to ensure that when a
- // channel is force-closed, the UTXO nursery has persisted the state of
- // the channel in the closure process and will recover the correct
+ // channel is force-closed, the contract court has persisted the state
+ // of the channel in the closure process and will recover the correct
// state when the system comes back on line. This restart tests state
// persistence at the beginning of the process, when the commitment
// transaction has been broadcast but not yet confirmed in a block.
ht.RestartNode(alice)
- // To give the neutrino backend some time to catch up with the chain,
- // we wait here until we have enough UTXOs to actually sweep the local
- // and remote anchor.
- const expectedUtxos = 6
- ht.AssertNumUTXOs(alice, expectedUtxos)
-
// We expect to see Alice's force close tx in the mempool.
- ht.GetNumTxsFromMempool(1)
+ ht.AssertNumTxsInMempool(1)
// Mine a block which should confirm the commitment transaction
// broadcast as a result of the force closure. Once mined, we also
@@ -259,46 +217,34 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
// The following restart is intended to ensure that outputs from the
// force close commitment transaction have been persisted once the
- // transaction has been confirmed, but before the outputs are spendable
- // (the "kindergarten" bucket.)
+ // transaction has been confirmed, but before the outputs are
+ // spendable.
ht.RestartNode(alice)
// Carol should offer her commit and anchor outputs to the sweeper.
sweepTxns := ht.AssertNumPendingSweeps(carol, 2)
- // Find Carol's anchor sweep.
+ // Identify Carol's pending sweeps.
var carolAnchor, carolCommit = sweepTxns[0], sweepTxns[1]
if carolAnchor.AmountSat != uint32(anchorSize) {
carolAnchor, carolCommit = carolCommit, carolAnchor
}
- // Mine a block to trigger Carol's sweeper to make decisions on the
- // anchor sweeping.
- ht.MineEmptyBlocks(1)
-
// Carol's sweep tx should be in the mempool already, as her output is
- // not timelocked.
+ // not timelocked. This sweep tx should spend her to_local output as
+ // the anchor output is not economical to spend.
carolTx := ht.GetNumTxsFromMempool(1)[0]
- // Carol's sweeping tx should have 2-input-1-output shape.
- require.Len(ht, carolTx.TxIn, 2)
+ // Carol's sweeping tx should have 1-input-1-output shape.
+ require.Len(ht, carolTx.TxIn, 1)
require.Len(ht, carolTx.TxOut, 1)
// Calculate the total fee Carol paid.
totalFeeCarol := ht.CalculateTxFee(carolTx)
- // If we have anchors, add an anchor resolution for carol.
- op := fmt.Sprintf("%v:%v", carolAnchor.Outpoint.TxidStr,
- carolAnchor.Outpoint.OutputIndex)
- carolReports[op] = &lnrpc.Resolution{
- ResolutionType: lnrpc.ResolutionType_ANCHOR,
- Outcome: lnrpc.ResolutionOutcome_CLAIMED,
- SweepTxid: carolTx.TxHash().String(),
- AmountSat: anchorSize,
- Outpoint: carolAnchor.Outpoint,
- }
-
- op = fmt.Sprintf("%v:%v", carolCommit.Outpoint.TxidStr,
+ // Carol's anchor report won't be created since it's uneconomical to
+ // sweep. So we expect to see only the commit sweep report.
+ op := fmt.Sprintf("%v:%v", carolCommit.Outpoint.TxidStr,
carolCommit.Outpoint.OutputIndex)
carolReports[op] = &lnrpc.Resolution{
ResolutionType: lnrpc.ResolutionType_COMMIT,
@@ -320,9 +266,9 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
// Alice should still have the anchor sweeping request.
ht.AssertNumPendingSweeps(alice, 1)
- // The following restart checks to ensure that outputs in the
- // kindergarten bucket are persisted while waiting for the required
- // number of confirmations to be reported.
+ // The following restart checks to ensure that outputs in the contract
+ // court are persisted while waiting for the required number of
+ // confirmations to be reported.
ht.RestartNode(alice)
// Alice should see the channel in her set of pending force closed
@@ -345,12 +291,12 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
aliceBalance = forceClose.Channel.LocalBalance
// At this point, the nursery should show that the commitment
- // output has 2 block left before its CSV delay expires. In
+ // output has 3 block left before its CSV delay expires. In
// total, we have mined exactly defaultCSV blocks, so the htlc
// outputs should also reflect that this many blocks have
// passed.
err = checkCommitmentMaturity(
- forceClose, commCsvMaturityHeight, 2,
+ forceClose, commCsvMaturityHeight, 3,
)
if err != nil {
return err
@@ -369,9 +315,9 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
}, defaultTimeout)
require.NoError(ht, err, "timeout while checking force closed channel")
- // Generate an additional block, which should cause the CSV delayed
- // output from the commitment txn to expire.
- ht.MineEmptyBlocks(1)
+ // Generate two blocks, which should cause the CSV delayed output from
+ // the commitment txn to expire.
+ ht.MineBlocks(2)
// At this point, the CSV will expire in the next block, meaning that
// the output should be offered to the sweeper.
@@ -381,14 +327,9 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
commitSweep, anchorSweep = anchorSweep, commitSweep
}
- // Restart Alice to ensure that she resumes watching the finalized
- // commitment sweep txid.
- ht.RestartNode(alice)
-
// Mine one block and the sweeping transaction should now be broadcast.
// So we fetch the node's mempool to ensure it has been properly
// broadcast.
- ht.MineEmptyBlocks(1)
sweepingTXID := ht.AssertNumTxsInMempool(1)[0]
// Fetch the sweep transaction, all input it's spending should be from
@@ -399,7 +340,12 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
"sweep transaction not spending from commit")
}
- // We expect a resolution which spends our commit output.
+ // Restart Alice to ensure that she resumes watching the finalized
+ // commitment sweep txid.
+ ht.RestartNode(alice)
+
+ // Alice's anchor report won't be created since it's uneconomical to
+ // sweep. We expect a resolution which spends our commit output.
op = fmt.Sprintf("%v:%v", commitSweep.Outpoint.TxidStr,
commitSweep.Outpoint.OutputIndex)
aliceReports[op] = &lnrpc.Resolution{
@@ -410,17 +356,6 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
AmountSat: uint64(aliceBalance),
}
- // Add alice's anchor to our expected set of reports.
- op = fmt.Sprintf("%v:%v", aliceAnchor.Outpoint.TxidStr,
- aliceAnchor.Outpoint.OutputIndex)
- aliceReports[op] = &lnrpc.Resolution{
- ResolutionType: lnrpc.ResolutionType_ANCHOR,
- Outcome: lnrpc.ResolutionOutcome_CLAIMED,
- SweepTxid: sweepingTXID.String(),
- Outpoint: aliceAnchor.Outpoint,
- AmountSat: uint64(anchorSize),
- }
-
// Check that we can find the commitment sweep in our set of known
// sweeps, using the simple transaction id ListSweeps output.
ht.AssertSweepFound(alice, sweepingTXID.String(), false, 0)
@@ -490,17 +425,13 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
// Advance the blockchain until just before the CLTV expires, nothing
// exciting should have happened during this time.
- ht.MineEmptyBlocks(cltvHeightDelta)
+ ht.MineBlocks(cltvHeightDelta)
// We now restart Alice, to ensure that she will broadcast the
// presigned htlc timeout txns after the delay expires after
// experiencing a while waiting for the htlc outputs to incubate.
ht.RestartNode(alice)
- // To give the neutrino backend some time to catch up with the chain,
- // we wait here until we have enough UTXOs to
- // ht.AssertNumUTXOs(alice, expectedUtxos)
-
// Alice should now see the channel in her set of pending force closed
// channels with one pending HTLC.
err = wait.NoError(func() error {
@@ -535,24 +466,23 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
// Now, generate the block which will cause Alice to offer the
// presigned htlc timeout txns to the sweeper.
- ht.MineEmptyBlocks(1)
+ ht.MineBlocks(1)
// Since Alice had numInvoices (6) htlcs extended to Carol before force
// closing, we expect Alice to broadcast an htlc timeout txn for each
- // one.
- ht.AssertNumPendingSweeps(alice, numInvoices)
+ // one. We also expect Alice to still have her anchor since it's not
+ // swept.
+ ht.AssertNumPendingSweeps(alice, numInvoices+1)
// Wait for them all to show up in the mempool
- //
- // NOTE: after restart, all the htlc timeout txns will be offered to
- // the sweeper with `Immediate` set to true, so they won't be
- // aggregated.
- htlcTxIDs := ht.AssertNumTxsInMempool(numInvoices)
+ htlcTxIDs := ht.AssertNumTxsInMempool(1)
// Retrieve each htlc timeout txn from the mempool, and ensure it is
- // well-formed. This entails verifying that each only spends from
- // output, and that output is from the commitment txn.
- numInputs := 2
+ // well-formed. The sweeping tx should spend all the htlc outputs.
+ //
+ // NOTE: We also add 1 output as the outgoing HTLC is swept using twice
+ // its value as its budget, so a wallet utxo is used.
+ numInputs := 6 + 1
// Construct a map of the already confirmed htlc timeout outpoints,
// that will count the number of times each is spent by the sweep txn.
@@ -561,6 +491,8 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
var htlcTxOutpointSet = make(map[wire.OutPoint]int)
var htlcLessFees uint64
+
+ //nolint:ll
for _, htlcTxID := range htlcTxIDs {
// Fetch the sweep transaction, all input it's spending should
// be from the commitment transaction which was broadcast
@@ -653,10 +585,10 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
// Generate a block that mines the htlc timeout txns. Doing so now
// activates the 2nd-stage CSV delayed outputs.
- ht.MineBlocksAndAssertNumTxes(1, numInvoices)
+ ht.MineBlocksAndAssertNumTxes(1, 1)
- // Alice is restarted here to ensure that she promptly moved the crib
- // outputs to the kindergarten bucket after the htlc timeout txns were
+ // Alice is restarted here to ensure that her contract court properly
+ // handles the 2nd-stage sweeps after the htlc timeout txns were
// confirmed.
ht.RestartNode(alice)
@@ -665,12 +597,19 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
currentHeight = int32(ht.CurrentHeight())
ht.Logf("current height: %v, htlcCsvMaturityHeight=%v", currentHeight,
htlcCsvMaturityHeight)
- numBlocks := int(htlcCsvMaturityHeight - uint32(currentHeight) - 2)
- ht.MineEmptyBlocks(numBlocks)
+ numBlocks := int(htlcCsvMaturityHeight - uint32(currentHeight) - 1)
+ ht.MineBlocks(numBlocks)
- // Restart Alice to ensure that she can recover from a failure before
- // having graduated the htlc outputs in the kindergarten bucket.
- ht.RestartNode(alice)
+ ht.AssertNumPendingSweeps(alice, numInvoices+1)
+
+ // Restart Alice to ensure that she can recover from a failure.
+ //
+ // TODO(yy): Skip this step for neutrino as it cannot recover the
+ // sweeping txns from the mempool. We need to also store the txns in
+ // the sweeper store to make it work for the neutrino case.
+ if !ht.IsNeutrinoBackend() {
+ ht.RestartNode(alice)
+ }
// Now that the channel has been fully swept, it should no longer show
// incubated, check to see that Alice's node still reports the channel
@@ -688,55 +627,13 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
}, defaultTimeout)
require.NoError(ht, err, "timeout while checking force closed channel")
- // Generate a block that causes Alice to sweep the htlc outputs in the
- // kindergarten bucket.
- ht.MineEmptyBlocks(1)
- ht.AssertNumPendingSweeps(alice, numInvoices)
-
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
-
- // A temp hack to ensure the CI is not blocking the current
- // development. There's a known issue in block sync among different
- // subsystems, which is scheduled to be fixed in 0.18.1.
- if ht.IsNeutrinoBackend() {
- // We expect the htlcs to be aggregated into one tx. However,
- // due to block sync issue, they may end up in two txns. Here
- // we assert that there are two txns found in the mempool - if
- // succeeded, it means the aggregation failed, and we won't
- // continue the test.
- //
- // NOTE: we don't check `len(mempool) == 1` because it will
- // give us false positive.
- err := wait.NoError(func() error {
- mempool := ht.Miner().GetRawMempool()
- if len(mempool) == 2 {
- return nil
- }
-
- return fmt.Errorf("expected 2 txes in mempool, found "+
- "%d", len(mempool))
- }, lntest.DefaultTimeout)
- ht.Logf("Assert num of txns got %v", err)
-
- // If there are indeed two txns found in the mempool, we won't
- // continue the test.
- if err == nil {
- ht.Log("Neutrino backend failed to aggregate htlc " +
- "sweeps!")
-
- // Clean the mempool.
- ht.MineBlocksAndAssertNumTxes(1, 2)
-
- return
- }
- }
+ ht.AssertNumPendingSweeps(alice, numInvoices+1)
// Wait for the single sweep txn to appear in the mempool.
- htlcSweepTxID := ht.AssertNumTxsInMempool(1)[0]
+ htlcSweepTxid := ht.AssertNumTxsInMempool(1)[0]
// Fetch the htlc sweep transaction from the mempool.
- htlcSweepTx := ht.GetRawTransaction(htlcSweepTxID)
+ htlcSweepTx := ht.GetRawTransaction(htlcSweepTxid)
// Ensure the htlc sweep transaction only has one input for each htlc
// Alice extended before force closing.
@@ -748,6 +645,7 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
// Ensure that each output spends from exactly one htlc timeout output.
for _, txIn := range htlcSweepTx.MsgTx().TxIn {
outpoint := txIn.PreviousOutPoint
+
// Check that the input is a confirmed htlc timeout txn.
_, ok := htlcTxOutpointSet[outpoint]
require.Truef(ht, ok, "htlc sweep output not spending from "+
@@ -785,11 +683,11 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
// Check that we can find the htlc sweep in our set of sweeps using
// the verbose output of the listsweeps output.
- ht.AssertSweepFound(alice, htlcSweepTx.Hash().String(), true, 0)
+ ht.AssertSweepFound(alice, htlcSweepTxid.String(), true, 0)
- // The following restart checks to ensure that the nursery store is
- // storing the txid of the previously broadcast htlc sweep txn, and
- // that it begins watching that txid after restarting.
+ // The following restart checks to ensure that the sweeper is storing
+ // the txid of the previously broadcast htlc sweep txn, and that it
+ // begins watching that txid after restarting.
ht.RestartNode(alice)
// Now that the channel has been fully swept, it should no longer show
@@ -805,7 +703,7 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
}
err = checkPendingHtlcStageAndMaturity(
- forceClose, 2, htlcCsvMaturityHeight-1, -1,
+ forceClose, 2, htlcCsvMaturityHeight-1, 0,
)
if err != nil {
return err
@@ -818,7 +716,7 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
// Generate the final block that sweeps all htlc funds into the user's
// wallet, and make sure the sweep is in this block.
block := ht.MineBlocksAndAssertNumTxes(1, 1)[0]
- ht.AssertTxInBlock(block, htlcSweepTxID)
+ ht.AssertTxInBlock(block, htlcSweepTxid)
// Now that the channel has been fully swept, it should no longer show
// up within the pending channels RPC.
@@ -847,12 +745,6 @@ func channelForceClosureTest(ht *lntest.HarnessTest,
carolExpectedBalance := btcutil.Amount(carolStartingBalance) +
pushAmt - totalFeeCarol
- // In addition, if this is an anchor-enabled channel, further add the
- // anchor size.
- if lntest.CommitTypeHasAnchors(channelType) {
- carolExpectedBalance += btcutil.Amount(anchorSize)
- }
-
require.Equal(ht, carolExpectedBalance,
btcutil.Amount(carolBalResp.ConfirmedBalance),
"carol's balance is incorrect")
@@ -873,8 +765,6 @@ func padCLTV(cltv uint32) uint32 {
// in the case where a counterparty tries to settle an HTLC with the wrong
// preimage.
func testFailingChannel(ht *lntest.HarnessTest) {
- const paymentAmt = 10000
-
chanAmt := lnd.MaxFundingAmount
// We'll introduce Carol, which will settle any incoming invoice with a
@@ -893,7 +783,7 @@ func testFailingChannel(ht *lntest.HarnessTest) {
invoice := &lnrpc.Invoice{
Memo: "testing",
RPreimage: preimage,
- Value: paymentAmt,
+ Value: invoiceAmt,
}
resp := carol.RPC.AddInvoice(invoice)
@@ -926,12 +816,12 @@ func testFailingChannel(ht *lntest.HarnessTest) {
// Carol will use the correct preimage to resolve the HTLC on-chain.
ht.AssertNumPendingSweeps(carol, 1)
- // Bring down the fee rate estimation, otherwise the following sweep
- // won't happen.
- ht.SetFeeEstimate(chainfee.FeePerKwFloor)
-
- // Mine a block to trigger Carol's sweeper to broadcast the sweeping
- // tx.
+ // Mine a block to trigger the sweep. This is needed because the
+ // preimage extraction logic from the link is not managed by the
+ // blockbeat, which means the preimage may be sent to the contest
+ // resolver after it's launched.
+ //
+ // TODO(yy): Expose blockbeat to the link layer.
ht.MineEmptyBlocks(1)
// Carol should have broadcast her sweeping tx.
@@ -944,9 +834,6 @@ func testFailingChannel(ht *lntest.HarnessTest) {
// Alice's should have one pending sweep request for her commit output.
ht.AssertNumPendingSweeps(alice, 1)
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
-
// Mine Alice's sweeping tx.
ht.MineBlocksAndAssertNumTxes(1, 1)
diff --git a/itest/lnd_forward_interceptor_test.go b/itest/lnd_forward_interceptor_test.go
index 0edf1e1098e..a58dc34154e 100644
--- a/itest/lnd_forward_interceptor_test.go
+++ b/itest/lnd_forward_interceptor_test.go
@@ -508,8 +508,7 @@ func testForwardInterceptorWireRecords(ht *lntest.HarnessTest) {
FeeLimitMsat: noFeeLimitMsat,
FirstHopCustomRecords: customRecords,
}
-
- _ = alice.RPC.SendPayment(sendReq)
+ ht.SendPaymentAssertInflight(alice, sendReq)
// We start the htlc interceptor with a simple implementation that saves
// all intercepted packets. These packets are held to simulate a
@@ -635,8 +634,7 @@ func testForwardInterceptorRestart(ht *lntest.HarnessTest) {
FeeLimitMsat: noFeeLimitMsat,
FirstHopCustomRecords: customRecords,
}
-
- _ = alice.RPC.SendPayment(sendReq)
+ ht.SendPaymentAssertInflight(alice, sendReq)
// We start the htlc interceptor with a simple implementation that saves
// all intercepted packets. These packets are held to simulate a
diff --git a/itest/lnd_funding_test.go b/itest/lnd_funding_test.go
index 0b08da32b32..4c045776e06 100644
--- a/itest/lnd_funding_test.go
+++ b/itest/lnd_funding_test.go
@@ -6,18 +6,13 @@ import (
"testing"
"time"
- "github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
- "github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/chainreg"
- "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/funding"
- "github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/labels"
"github.com/lightningnetwork/lnd/lnrpc"
- "github.com/lightningnetwork/lnd/lnrpc/signrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/lightningnetwork/lnd/lntest/node"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
@@ -583,8 +578,8 @@ func runExternalFundingScriptEnforced(ht *lntest.HarnessTest) {
// a transaction that will never be published.
const thawHeight uint32 = 10
const chanSize = funding.MaxBtcFundingAmount
- fundingShim1, chanPoint1 := deriveFundingShim(
- ht, carol, dave, chanSize, thawHeight, false, commitmentType,
+ fundingShim1, chanPoint1 := ht.DeriveFundingShim(
+ carol, dave, chanSize, thawHeight, false, commitmentType,
)
ht.OpenChannelAssertPending(
carol, dave, lntest.OpenChannelParams{
@@ -599,8 +594,8 @@ func runExternalFundingScriptEnforced(ht *lntest.HarnessTest) {
// externally funded, we should still be able to open another one. Let's
// do exactly that now. For this one we publish the transaction so we
// can mine it later.
- fundingShim2, chanPoint2 := deriveFundingShim(
- ht, carol, dave, chanSize, thawHeight, true, commitmentType,
+ fundingShim2, chanPoint2 := ht.DeriveFundingShim(
+ carol, dave, chanSize, thawHeight, true, commitmentType,
)
// At this point, we'll now carry out the normal basic channel funding
@@ -699,8 +694,8 @@ func runExternalFundingTaproot(ht *lntest.HarnessTest) {
// a transaction that will never be published.
const thawHeight uint32 = 10
const chanSize = funding.MaxBtcFundingAmount
- fundingShim1, chanPoint1 := deriveFundingShim(
- ht, carol, dave, chanSize, thawHeight, false, commitmentType,
+ fundingShim1, chanPoint1 := ht.DeriveFundingShim(
+ carol, dave, chanSize, thawHeight, false, commitmentType,
)
ht.OpenChannelAssertPending(carol, dave, lntest.OpenChannelParams{
Amt: chanSize,
@@ -715,8 +710,8 @@ func runExternalFundingTaproot(ht *lntest.HarnessTest) {
// externally funded, we should still be able to open another one. Let's
// do exactly that now. For this one we publish the transaction so we
// can mine it later.
- fundingShim2, chanPoint2 := deriveFundingShim(
- ht, carol, dave, chanSize, thawHeight, true, commitmentType,
+ fundingShim2, chanPoint2 := ht.DeriveFundingShim(
+ carol, dave, chanSize, thawHeight, true, commitmentType,
)
// At this point, we'll now carry out the normal basic channel funding
@@ -1163,122 +1158,6 @@ func ensurePolicy(ht *lntest.HarnessTest, alice, peer *node.HarnessNode,
require.EqualValues(ht, expectedFeeRate, alicePolicy.FeeRateMilliMsat)
}
-// deriveFundingShim creates a channel funding shim by deriving the necessary
-// keys on both sides.
-func deriveFundingShim(ht *lntest.HarnessTest, carol, dave *node.HarnessNode,
- chanSize btcutil.Amount, thawHeight uint32, publish bool,
- commitType lnrpc.CommitmentType) (*lnrpc.FundingShim,
- *lnrpc.ChannelPoint) {
-
- keyLoc := &signrpc.KeyLocator{KeyFamily: 9999}
- carolFundingKey := carol.RPC.DeriveKey(keyLoc)
- daveFundingKey := dave.RPC.DeriveKey(keyLoc)
-
- // Now that we have the multi-sig keys for each party, we can manually
- // construct the funding transaction. We'll instruct the backend to
- // immediately create and broadcast a transaction paying out an exact
- // amount. Normally this would reside in the mempool, but we just
- // confirm it now for simplicity.
- var (
- fundingOutput *wire.TxOut
- musig2 bool
- err error
- )
- if commitType == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- var carolKey, daveKey *btcec.PublicKey
- carolKey, err = btcec.ParsePubKey(carolFundingKey.RawKeyBytes)
- require.NoError(ht, err)
- daveKey, err = btcec.ParsePubKey(daveFundingKey.RawKeyBytes)
- require.NoError(ht, err)
-
- _, fundingOutput, err = input.GenTaprootFundingScript(
- carolKey, daveKey, int64(chanSize),
- fn.None[chainhash.Hash](),
- )
- require.NoError(ht, err)
-
- musig2 = true
- } else {
- _, fundingOutput, err = input.GenFundingPkScript(
- carolFundingKey.RawKeyBytes, daveFundingKey.RawKeyBytes,
- int64(chanSize),
- )
- require.NoError(ht, err)
- }
-
- var txid *chainhash.Hash
- targetOutputs := []*wire.TxOut{fundingOutput}
- if publish {
- txid = ht.SendOutputsWithoutChange(targetOutputs, 5)
- } else {
- tx := ht.CreateTransaction(targetOutputs, 5)
-
- txHash := tx.TxHash()
- txid = &txHash
- }
-
- // At this point, we can being our external channel funding workflow.
- // We'll start by generating a pending channel ID externally that will
- // be used to track this new funding type.
- pendingChanID := ht.Random32Bytes()
-
- // Now that we have the pending channel ID, Dave (our responder) will
- // register the intent to receive a new channel funding workflow using
- // the pending channel ID.
- chanPoint := &lnrpc.ChannelPoint{
- FundingTxid: &lnrpc.ChannelPoint_FundingTxidBytes{
- FundingTxidBytes: txid[:],
- },
- }
- chanPointShim := &lnrpc.ChanPointShim{
- Amt: int64(chanSize),
- ChanPoint: chanPoint,
- LocalKey: &lnrpc.KeyDescriptor{
- RawKeyBytes: daveFundingKey.RawKeyBytes,
- KeyLoc: &lnrpc.KeyLocator{
- KeyFamily: daveFundingKey.KeyLoc.KeyFamily,
- KeyIndex: daveFundingKey.KeyLoc.KeyIndex,
- },
- },
- RemoteKey: carolFundingKey.RawKeyBytes,
- PendingChanId: pendingChanID,
- ThawHeight: thawHeight,
- Musig2: musig2,
- }
- fundingShim := &lnrpc.FundingShim{
- Shim: &lnrpc.FundingShim_ChanPointShim{
- ChanPointShim: chanPointShim,
- },
- }
- dave.RPC.FundingStateStep(&lnrpc.FundingTransitionMsg{
- Trigger: &lnrpc.FundingTransitionMsg_ShimRegister{
- ShimRegister: fundingShim,
- },
- })
-
- // If we attempt to register the same shim (has the same pending chan
- // ID), then we should get an error.
- dave.RPC.FundingStateStepAssertErr(&lnrpc.FundingTransitionMsg{
- Trigger: &lnrpc.FundingTransitionMsg_ShimRegister{
- ShimRegister: fundingShim,
- },
- })
-
- // We'll take the chan point shim we just registered for Dave (the
- // responder), and swap the local/remote keys before we feed it in as
- // Carol's funding shim as the initiator.
- fundingShim.GetChanPointShim().LocalKey = &lnrpc.KeyDescriptor{
- RawKeyBytes: carolFundingKey.RawKeyBytes,
- KeyLoc: &lnrpc.KeyLocator{
- KeyFamily: carolFundingKey.KeyLoc.KeyFamily,
- KeyIndex: carolFundingKey.KeyLoc.KeyIndex,
- },
- }
- fundingShim.GetChanPointShim().RemoteKey = daveFundingKey.RawKeyBytes
-
- return fundingShim, chanPoint
-}
-
// testChannelFundingWithUnstableUtxos tests channel openings with restricted
// utxo selection. Internal wallet utxos might be restricted due to another
// subsystems still using it therefore it would be unsecure to use them for
@@ -1297,6 +1176,7 @@ func testChannelFundingWithUnstableUtxos(ht *lntest.HarnessTest) {
// First, we'll create two new nodes that we'll use to open channel
// between for this test.
carol := ht.NewNode("carol", nil)
+
// We'll attempt at max 2 pending channels, so Dave will need to accept
// two pending ones.
dave := ht.NewNode("dave", []string{
@@ -1375,9 +1255,6 @@ func testChannelFundingWithUnstableUtxos(ht *lntest.HarnessTest) {
// Make sure Carol sees her to_remote output from the force close tx.
ht.AssertNumPendingSweeps(carol, 1)
- // Mine one block to trigger the sweep transaction.
- ht.MineEmptyBlocks(1)
-
// We need to wait for carol initiating the sweep of the to_remote
// output of chanPoint2.
utxo := ht.AssertNumUTXOsUnconfirmed(carol, 1)[0]
@@ -1435,9 +1312,6 @@ func testChannelFundingWithUnstableUtxos(ht *lntest.HarnessTest) {
// Make sure Carol sees her to_remote output from the force close tx.
ht.AssertNumPendingSweeps(carol, 1)
- // Mine one block to trigger the sweep transaction.
- ht.MineEmptyBlocks(1)
-
// Wait for the to_remote sweep tx to show up in carol's wallet.
ht.AssertNumUTXOsUnconfirmed(carol, 1)
diff --git a/itest/lnd_hold_invoice_force_test.go b/itest/lnd_hold_invoice_force_test.go
index 7c616e7a50c..d9b85171077 100644
--- a/itest/lnd_hold_invoice_force_test.go
+++ b/itest/lnd_hold_invoice_force_test.go
@@ -44,14 +44,18 @@ func testHoldInvoiceForceClose(ht *lntest.HarnessTest) {
TimeoutSeconds: 60,
FeeLimitMsat: noFeeLimitMsat,
}
- alice.RPC.SendPayment(req)
+ ht.SendPaymentAssertInflight(alice, req)
ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
// Once the HTLC has cleared, alice and bob should both have a single
// htlc locked in.
- ht.AssertActiveHtlcs(alice, payHash[:])
- ht.AssertActiveHtlcs(bob, payHash[:])
+ //
+ // Alice should have one outgoing HTLCs on channel Alice -> Bob.
+ ht.AssertOutgoingHTLCActive(alice, chanPoint, payHash[:])
+
+ // Bob should have one incoming HTLC on channel Alice -> Bob.
+ ht.AssertIncomingHTLCActive(bob, chanPoint, payHash[:])
// Get our htlc expiry height and current block height so that we
// can mine the exact number of blocks required to expire the htlc.
diff --git a/itest/lnd_htlc_timeout_resolver_test.go b/itest/lnd_htlc_timeout_resolver_test.go
new file mode 100644
index 00000000000..cd1dd3d483a
--- /dev/null
+++ b/itest/lnd_htlc_timeout_resolver_test.go
@@ -0,0 +1,381 @@
+package itest
+
+import (
+ "github.com/btcsuite/btcd/btcutil"
+ "github.com/lightningnetwork/lnd/chainreg"
+ "github.com/lightningnetwork/lnd/lncfg"
+ "github.com/lightningnetwork/lnd/lnrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/routerrpc"
+ "github.com/lightningnetwork/lnd/lntest"
+ "github.com/lightningnetwork/lnd/lntest/node"
+ "github.com/lightningnetwork/lnd/routing"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ finalCltvDelta = routing.MinCLTVDelta // 18.
+ thawHeightDelta = finalCltvDelta * 2 // 36.
+)
+
+// makeRouteHints creates a route hints that will allow Carol to be reached
+// using an unadvertised channel created by Bob (Bob -> Carol). If the zeroConf
+// bool is set, then the scid alias of Bob will be used in place.
+func makeRouteHints(bob, carol *node.HarnessNode,
+ zeroConf bool) []*lnrpc.RouteHint {
+
+ carolChans := carol.RPC.ListChannels(
+ &lnrpc.ListChannelsRequest{},
+ )
+
+ carolChan := carolChans.Channels[0]
+
+ hopHint := &lnrpc.HopHint{
+ NodeId: carolChan.RemotePubkey,
+ ChanId: carolChan.ChanId,
+ FeeBaseMsat: uint32(
+ chainreg.DefaultBitcoinBaseFeeMSat,
+ ),
+ FeeProportionalMillionths: uint32(
+ chainreg.DefaultBitcoinFeeRate,
+ ),
+ CltvExpiryDelta: chainreg.DefaultBitcoinTimeLockDelta,
+ }
+
+ if zeroConf {
+ bobChans := bob.RPC.ListChannels(
+ &lnrpc.ListChannelsRequest{},
+ )
+
+ // Now that we have Bob's channels, scan for the channel he has
+ // open to Carol so we can use the proper scid.
+ var found bool
+ for _, bobChan := range bobChans.Channels {
+ if bobChan.RemotePubkey == carol.PubKeyStr {
+ hopHint.ChanId = bobChan.AliasScids[0]
+
+ found = true
+
+ break
+ }
+ }
+ if !found {
+ bob.Fatalf("unable to create route hint")
+ }
+ }
+
+ return []*lnrpc.RouteHint{
+ {
+ HopHints: []*lnrpc.HopHint{hopHint},
+ },
+ }
+}
+
+// testHtlcTimeoutResolverExtractPreimageRemote tests that in the multi-hop
+// setting, Alice->Bob->Carol, when Bob's outgoing HTLC is swept by Carol using
+// the 2nd level success tx2nd level success tx, Bob's timeout resolver will
+// extract the preimage from the sweep tx found in mempool. The 2nd level
+// success tx is broadcast by Carol and spends the outpoint on her commit tx.
+func testHtlcTimeoutResolverExtractPreimageRemote(ht *lntest.HarnessTest) {
+ // For neutrino backend there's no mempool source so we skip it. The
+ // test of extracting preimage from blocks has already been covered in
+ // other tests.
+ if ht.IsNeutrinoBackend() {
+ ht.Skip("skipping neutrino")
+ }
+
+ // Set the min relay feerate to be 10 sat/vbyte so the non-CPFP anchor
+ // is never swept.
+ //
+ // TODO(yy): delete this line once the normal anchor sweeping is
+ // removed.
+ ht.SetMinRelayFeerate(10_000)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{Amt: chanAmt}
+ cfg := node.CfgAnchor
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ // First, we'll create a three hop network: Alice -> Bob -> Carol, with
+ // Carol refusing to actually settle or directly cancel any HTLC's
+ // self.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ aliceChanPoint, bobChanPoint := chanPoints[0], chanPoints[1]
+
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
+
+ // With the network active, we'll now add a new hodl invoice at Carol's
+ // end. Make sure the cltv expiry delta is large enough, otherwise Bob
+ // won't send out the outgoing htlc.
+ preimage := ht.RandomPreimage()
+ payHash := preimage.Hash()
+ invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
+ Value: 100_000,
+ CltvExpiry: finalCltvDelta,
+ Hash: payHash[:],
+ }
+ eveInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
+
+ // Subscribe the invoice.
+ stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
+
+ // Now that we've created the invoice, we'll send a single payment from
+ // Alice to Carol. We won't wait for the response however, as Carol
+ // will not immediately settle the payment.
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: eveInvoice.PaymentRequest,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ // Once the payment sent, Alice should have one outgoing HTLC active.
+ ht.AssertOutgoingHTLCActive(alice, aliceChanPoint, payHash[:])
+
+ // Bob should have two HTLCs active. One incoming HTLC from Alice, and
+ // one outgoing to Carol.
+ ht.AssertIncomingHTLCActive(bob, aliceChanPoint, payHash[:])
+ htlc := ht.AssertOutgoingHTLCActive(bob, bobChanPoint, payHash[:])
+
+ // Carol should have one incoming HTLC from Bob.
+ ht.AssertIncomingHTLCActive(carol, bobChanPoint, payHash[:])
+
+ // Wait for Carol to mark invoice as accepted. There is a small gap to
+ // bridge between adding the htlc to the channel and executing the exit
+ // hop logic.
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
+
+ // Bob now goes offline so the link between Bob and Carol is broken.
+ restartBob := ht.SuspendNode(bob)
+
+ // Carol now settles the invoice, since her link with Bob is broken,
+ // Bob won't know the preimage.
+ carol.RPC.SettleInvoice(preimage[:])
+
+ // We'll now mine enough blocks to trigger Carol's broadcast of her
+ // commitment transaction due to the fact that the HTLC is about to
+ // timeout. With the default incoming broadcast delta of 10, this
+ // will be the htlc expiry height minus 10.
+ numBlocks := padCLTV(uint32(
+ invoiceReq.CltvExpiry - incomingBroadcastDelta,
+ ))
+ ht.MineBlocks(int(numBlocks))
+
+ // Mine the two txns made from Carol,
+ // - the force close tx.
+ // - the anchor sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // With the closing transaction confirmed, we should expect Carol's
+ // HTLC success transaction to be offered to the sweeper. along with her
+ // anchor output. Note that the anchor output is uneconomical to sweep.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // We should now have Carol's htlc success tx in the mempool.
+ ht.AssertNumTxsInMempool(1)
+
+ // Restart Bob. Once he finishes syncing the channel state, he should
+ // notice the force close from Carol.
+ require.NoError(ht, restartBob())
+
+ // Get the current height to compute number of blocks to mine to
+ // trigger the htlc timeout resolver from Bob.
+ height := ht.CurrentHeight()
+
+ // We'll now mine enough blocks to trigger Bob's timeout resolver.
+ numBlocks = htlc.ExpirationHeight - height -
+ lncfg.DefaultOutgoingBroadcastDelta
+
+ // Mine empty blocks so Carol's htlc success tx stays in mempool. Once
+ // the height is reached, Bob's timeout resolver will resolve the htlc
+ // by extracing the preimage from the mempool.
+ ht.MineEmptyBlocks(int(numBlocks))
+
+ // Finally, check that the Alice's payment is marked as succeeded as
+ // Bob has settled the htlc using the preimage extracted from Carol's
+ // 2nd level success tx.
+ ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
+
+ // Mine a block to clean the mempool.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // NOTE: for non-standby nodes there's no need to clean up the force
+ // close as long as the mempool is cleaned.
+ ht.CleanShutDown()
+}
+
+// testHtlcTimeoutResolverExtractPreimage tests that in the multi-hop setting,
+// Alice->Bob->Carol, when Bob's outgoing HTLC is swept by Carol using the
+// direct preimage spend, Bob's timeout resolver will extract the preimage from
+// the sweep tx found in mempool. The direct spend tx is broadcast by Carol and
+// spends the outpoint on Bob's commit tx.
+func testHtlcTimeoutResolverExtractPreimageLocal(ht *lntest.HarnessTest) {
+ // For neutrino backend there's no mempool source so we skip it. The
+ // test of extracting preimage from blocks has already been covered in
+ // other tests.
+ if ht.IsNeutrinoBackend() {
+ ht.Skip("skipping neutrino")
+ }
+
+ // Set the min relay feerate to be 10 sat/vbyte so the non-CPFP anchor
+ // is never swept.
+ //
+ // TODO(yy): delete this line once the normal anchor sweeping is
+ // removed.
+ ht.SetMinRelayFeerate(10_000)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{Amt: chanAmt}
+ cfg := node.CfgAnchor
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ // First, we'll create a three hop network: Alice -> Bob -> Carol, with
+ // Carol refusing to actually settle or directly cancel any HTLC's
+ // self.
+ // Create a three hop network: Alice -> Bob -> Carol.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ aliceChanPoint, bobChanPoint := chanPoints[0], chanPoints[1]
+
+ // With the network active, we'll now add a new hodl invoice at Carol's
+ // end. Make sure the cltv expiry delta is large enough, otherwise Bob
+ // won't send out the outgoing htlc.
+ preimage := ht.RandomPreimage()
+ payHash := preimage.Hash()
+ invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
+ Value: 100_000,
+ CltvExpiry: finalCltvDelta,
+ Hash: payHash[:],
+ }
+ carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
+
+ // Record the height which the invoice will expire.
+ invoiceExpiry := ht.CurrentHeight() + uint32(invoiceReq.CltvExpiry)
+
+ // Subscribe the invoice.
+ stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
+
+ // Now that we've created the invoice, we'll send a single payment from
+ // Alice to Carol. We won't wait for the response however, as Carol
+ // will not immediately settle the payment.
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: carolInvoice.PaymentRequest,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ // Once the payment sent, Alice should have one outgoing HTLC active.
+ ht.AssertOutgoingHTLCActive(alice, aliceChanPoint, payHash[:])
+
+ // Bob should have two HTLCs active. One incoming HTLC from Alice, and
+ // one outgoing to Carol.
+ ht.AssertIncomingHTLCActive(bob, aliceChanPoint, payHash[:])
+ ht.AssertOutgoingHTLCActive(bob, bobChanPoint, payHash[:])
+
+ // Carol should have one incoming HTLC from Bob.
+ ht.AssertIncomingHTLCActive(carol, bobChanPoint, payHash[:])
+
+ // Wait for Carol to mark invoice as accepted. There is a small gap to
+ // bridge between adding the htlc to the channel and executing the exit
+ // hop logic.
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
+
+ // Bob now goes offline so the link between Bob and Carol is broken.
+ restartBob := ht.SuspendNode(bob)
+
+ // Carol now settles the invoice, since her link with Bob is broken,
+ // Bob won't know the preimage.
+ carol.RPC.SettleInvoice(preimage[:])
+
+ // Stop Carol so it's easier to check the mempool's state since she
+ // will broadcast the anchor sweeping once Bob force closes.
+ restartCarol := ht.SuspendNode(carol)
+
+ // Restart Bob to force close the channel.
+ require.NoError(ht, restartBob())
+
+ // Bob force closes the channel, which gets his commitment tx into the
+ // mempool.
+ ht.CloseChannelAssertPending(bob, bobChanPoint, true)
+
+ // Mine Bob's force close tx.
+ ht.MineClosingTx(bobChanPoint)
+
+ // Once Bob's force closing tx is confirmed, he will re-offer the
+ // anchor output to his sweeper, which won't be swept due to it being
+ // uneconomical.
+ ht.AssertNumPendingSweeps(bob, 1)
+
+ // Mine 3 blocks so the output will be offered to the sweeper.
+ ht.MineBlocks(defaultCSV - 1)
+
+ // Bob should have two pending sweeps now,
+ // - the commit output.
+ // - the anchor output, uneconomical.
+ ht.AssertNumPendingSweeps(bob, 2)
+
+ // Mine a block to confirm Bob's sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ ht.Logf("Invoice expire height: %d, current: %d", invoiceExpiry,
+ ht.CurrentHeight())
+
+ // We'll now mine enough blocks to trigger Carol's sweeping of the htlc
+ // via the direct spend.
+ numBlocks := padCLTV(
+ invoiceExpiry - ht.CurrentHeight() - incomingBroadcastDelta,
+ )
+ ht.MineBlocks(int(numBlocks))
+
+ // Restart Carol to sweep the htlc output.
+ require.NoError(ht, restartCarol())
+
+ // With the above blocks mined, we should expect Carol's to offer the
+ // htlc output on Bob's commitment to the sweeper.
+ //
+ // Carol should two pending sweeps,
+ // - htlc output.
+ // - anchor output, uneconomical.
+ ht.AssertNumPendingSweeps(carol, 2)
+
+ // Check the current mempool state and we should see,
+ // - Carol's direct spend tx, which contains the preimage.
+ // - Carol's anchor sweep tx cannot be broadcast as it's uneconomical.
+ ht.AssertNumTxsInMempool(1)
+
+ // We'll now mine enough blocks to trigger Bob's htlc timeout resolver
+ // to act. Once his timeout resolver starts, it will extract the
+ // preimage from Carol's direct spend tx found in the mempool.
+ resp := ht.AssertNumPendingForceClose(bob, 1)[0]
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+
+ ht.Logf("Bob's timelock to_local output=%v, timelock on second stage "+
+ "htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ // Mine empty blocks so Carol's direct spend tx stays in mempool. Once
+ // the height is reached, Bob's timeout resolver will resolve the htlc
+ // by extracing the preimage from the mempool.
+ //
+ // TODO(yy): there's no need to wait till the HTLC's CLTV is reached,
+ // Bob's outgoing contest resolver can also monitor the mempool and
+ // resolve the payment even earlier.
+ ht.MineEmptyBlocks(int(resp.PendingHtlcs[0].BlocksTilMaturity))
+
+ // Finally, check that the Alice's payment is marked as succeeded as
+ // Bob has settled the htlc using the preimage extracted from Carol's
+ // direct spend tx.
+ ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
+
+ // NOTE: for non-standby nodes there's no need to clean up the force
+ // close as long as the mempool is cleaned.
+ ht.CleanShutDown()
+}
diff --git a/itest/lnd_misc_test.go b/itest/lnd_misc_test.go
index 02e3386e6a8..33a7067552e 100644
--- a/itest/lnd_misc_test.go
+++ b/itest/lnd_misc_test.go
@@ -632,8 +632,10 @@ func testRejectHTLC(ht *lntest.HarnessTest) {
TimeoutSeconds: 60,
FeeLimitMsat: noFeeLimitMsat,
}
- payStream := alice.RPC.SendPayment(paymentReq)
- ht.AssertPaymentStatusFromStream(payStream, lnrpc.Payment_FAILED)
+ ht.SendPaymentAssertFail(
+ alice, paymentReq,
+ lnrpc.PaymentFailureReason_FAILURE_REASON_NO_ROUTE,
+ )
ht.AssertLastHTLCError(alice, lnrpc.Failure_CHANNEL_DISABLED)
diff --git a/itest/lnd_multi-hop_force_close_test.go b/itest/lnd_multi-hop_force_close_test.go
new file mode 100644
index 00000000000..fc417c5d748
--- /dev/null
+++ b/itest/lnd_multi-hop_force_close_test.go
@@ -0,0 +1,3218 @@
+package itest
+
+import (
+ "testing"
+
+ "github.com/btcsuite/btcd/btcutil"
+ "github.com/lightningnetwork/lnd/lncfg"
+ "github.com/lightningnetwork/lnd/lnrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/routerrpc"
+ "github.com/lightningnetwork/lnd/lntest"
+ "github.com/lightningnetwork/lnd/lntest/node"
+ "github.com/lightningnetwork/lnd/lntest/rpc"
+ "github.com/lightningnetwork/lnd/lntypes"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ chanAmt = 1_000_000
+ invoiceAmt = 100_000
+ htlcAmt = btcutil.Amount(300_000)
+
+ incomingBroadcastDelta = lncfg.DefaultIncomingBroadcastDelta
+)
+
+var leasedType = lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE
+
+// multiHopForceCloseTestCases defines a set of tests that focuses on the
+// behavior of the force close in a multi-hop scenario.
+//
+//nolint:ll
+var multiHopForceCloseTestCases = []*lntest.TestCase{
+ {
+ Name: "multihop local claim outgoing htlc anchor",
+ TestFunc: testLocalClaimOutgoingHTLCAnchor,
+ },
+ {
+ Name: "multihop local claim outgoing htlc simple taproot",
+ TestFunc: testLocalClaimOutgoingHTLCSimpleTaproot,
+ },
+ {
+ Name: "multihop local claim outgoing htlc leased",
+ TestFunc: testLocalClaimOutgoingHTLCLeased,
+ },
+ {
+ Name: "multihop receiver preimage claim anchor",
+ TestFunc: testMultiHopReceiverPreimageClaimAnchor,
+ },
+ {
+ Name: "multihop receiver preimage claim simple taproot",
+ TestFunc: testMultiHopReceiverPreimageClaimSimpleTaproot,
+ },
+ {
+ Name: "multihop receiver preimage claim leased",
+ TestFunc: testMultiHopReceiverPreimageClaimLeased,
+ },
+ {
+ Name: "multihop local force close before timeout anchor",
+ TestFunc: testLocalForceCloseBeforeTimeoutAnchor,
+ },
+ {
+ Name: "multihop local force close before timeout simple taproot",
+ TestFunc: testLocalForceCloseBeforeTimeoutSimpleTaproot,
+ },
+ {
+ Name: "multihop local force close before timeout leased",
+ TestFunc: testLocalForceCloseBeforeTimeoutLeased,
+ },
+ {
+ Name: "multihop remote force close before timeout anchor",
+ TestFunc: testRemoteForceCloseBeforeTimeoutAnchor,
+ },
+ {
+ Name: "multihop remote force close before timeout simple taproot",
+ TestFunc: testRemoteForceCloseBeforeTimeoutSimpleTaproot,
+ },
+ {
+ Name: "multihop remote force close before timeout leased",
+ TestFunc: testRemoteForceCloseBeforeTimeoutLeased,
+ },
+ {
+ Name: "multihop local claim incoming htlc anchor",
+ TestFunc: testLocalClaimIncomingHTLCAnchor,
+ },
+ {
+ Name: "multihop local claim incoming htlc simple taproot",
+ TestFunc: testLocalClaimIncomingHTLCSimpleTaproot,
+ },
+ {
+ Name: "multihop local claim incoming htlc leased",
+ TestFunc: testLocalClaimIncomingHTLCLeased,
+ },
+ {
+ Name: "multihop local preimage claim anchor",
+ TestFunc: testLocalPreimageClaimAnchor,
+ },
+ {
+ Name: "multihop local preimage claim simple taproot",
+ TestFunc: testLocalPreimageClaimSimpleTaproot,
+ },
+ {
+ Name: "multihop local preimage claim leased",
+ TestFunc: testLocalPreimageClaimLeased,
+ },
+ {
+ Name: "multihop htlc aggregation anchor",
+ TestFunc: testHtlcAggregaitonAnchor,
+ },
+ {
+ Name: "multihop htlc aggregation simple taproot",
+ TestFunc: testHtlcAggregaitonSimpleTaproot,
+ },
+ {
+ Name: "multihop htlc aggregation leased",
+ TestFunc: testHtlcAggregaitonLeased,
+ },
+}
+
+// testLocalClaimOutgoingHTLCAnchor tests `runLocalClaimOutgoingHTLC` with
+// anchor channel.
+func testLocalClaimOutgoingHTLCAnchor(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // anchor channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{Amt: chanAmt}
+
+ cfg := node.CfgAnchor
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalClaimOutgoingHTLC(st, cfgs, openChannelParams)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: lnrpc.CommitmentType_ANCHORS,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and anchor.
+ cfg := node.CfgZeroConf
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalClaimOutgoingHTLC(st, cfgs, openChannelParams)
+ })
+}
+
+// testLocalClaimOutgoingHTLCSimpleTaproot tests `runLocalClaimOutgoingHTLC`
+// with simple taproot channel.
+func testLocalClaimOutgoingHTLCSimpleTaproot(ht *lntest.HarnessTest) {
+ c := lnrpc.CommitmentType_SIMPLE_TAPROOT
+
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // simple taproot channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ cfg := node.CfgSimpleTaproot
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalClaimOutgoingHTLC(st, cfgs, openChannelParams)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf simple taproot channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgSimpleTaproot
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalClaimOutgoingHTLC(st, cfgs, openChannelParams)
+ })
+}
+
+// testLocalClaimOutgoingHTLCLeased tests `runLocalClaimOutgoingHTLC` with
+// script enforced lease channel.
+func testLocalClaimOutgoingHTLCLeased(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // leased channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: leasedType,
+ }
+
+ cfg := node.CfgLeased
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalClaimOutgoingHTLC(st, cfgs, openChannelParams)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: leasedType,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgLeased
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalClaimOutgoingHTLC(st, cfgs, openChannelParams)
+ })
+}
+
+// runLocalClaimOutgoingHTLC tests that in a multi-hop scenario, if the
+// outgoing HTLC is about to time out, then we'll go to chain in order to claim
+// it using the HTLC timeout transaction. Any dust HTLC's should be immediately
+// canceled backwards. Once the timeout has been reached, then we should sweep
+// it on-chain, and cancel the HTLC backwards.
+func runLocalClaimOutgoingHTLC(ht *lntest.HarnessTest,
+ cfgs [][]string, params lntest.OpenChannelParams) {
+
+ // Create a three hop network: Alice -> Bob -> Carol.
+ _, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+
+ // For neutrino backend, we need to fund one more UTXO for Bob so he
+ // can sweep his outputs.
+ if ht.IsNeutrinoBackend() {
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, bob)
+ }
+
+ // Bob should have enough wallet UTXOs here to sweep the HTLC in the
+ // end of this test. However, due to a known issue, Bob's wallet may
+ // report there's no UTXO available. For details,
+ // - https://github.com/lightningnetwork/lnd/issues/8786
+ //
+ // TODO(yy): remove this step once the issue is resolved.
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, bob)
+
+ // Now that our channels are set up, we'll send two HTLC's from Alice
+ // to Carol. The first HTLC will be universally considered "dust",
+ // while the second will be a proper fully valued HTLC.
+ const dustHtlcAmt = btcutil.Amount(100)
+
+ // We'll create two random payment hashes unknown to carol, then send
+ // each of them by manually specifying the HTLC details.
+ carolPubKey := carol.PubKey[:]
+ dustPayHash := ht.Random32Bytes()
+ payHash := ht.Random32Bytes()
+
+ // If this is a taproot channel, then we'll need to make some manual
+ // route hints so Alice can actually find a route.
+ var routeHints []*lnrpc.RouteHint
+ if params.CommitmentType == lnrpc.CommitmentType_SIMPLE_TAPROOT {
+ routeHints = makeRouteHints(bob, carol, params.ZeroConf)
+ }
+
+ req := &routerrpc.SendPaymentRequest{
+ Dest: carolPubKey,
+ Amt: int64(dustHtlcAmt),
+ PaymentHash: dustPayHash,
+ FinalCltvDelta: finalCltvDelta,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ RouteHints: routeHints,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ req = &routerrpc.SendPaymentRequest{
+ Dest: carolPubKey,
+ Amt: int64(htlcAmt),
+ PaymentHash: payHash,
+ FinalCltvDelta: finalCltvDelta,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ RouteHints: routeHints,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLC pending on all of them.
+ //
+ // Alice should have two outgoing HTLCs on channel Alice -> Bob.
+ ht.AssertNumActiveHtlcs(alice, 2)
+
+ // Bob should have two incoming HTLCs on channel Alice -> Bob, and two
+ // outgoing HTLCs on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(bob, 4)
+
+ // Carol should have two incoming HTLCs on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(carol, 2)
+
+ // We'll now mine enough blocks to trigger Bob's force close the
+ // channel Bob=>Carol due to the fact that the HTLC is about to
+ // timeout. With the default outgoing broadcast delta of zero, this
+ // will be the same height as the htlc expiry height.
+ numBlocks := padCLTV(
+ uint32(finalCltvDelta - lncfg.DefaultOutgoingBroadcastDelta),
+ )
+ ht.MineBlocks(int(numBlocks))
+
+ // Bob's force close tx should have the following outputs,
+ // 1. anchor output.
+ // 2. to_local output, which is CSV locked.
+ // 3. outgoing HTLC output, which has expired.
+ //
+ // Bob's anchor output should be offered to his sweeper since Bob has
+ // time-sensitive HTLCs - we expect both anchors to be offered, while
+ // the sweeping of the remote anchor will be marked as failed due to
+ // `testmempoolaccept` check.
+ //
+ // For neutrino backend, there's no way to know the sweeping of the
+ // remote anchor is failed, so Bob still sees two pending sweeps.
+ if ht.IsNeutrinoBackend() {
+ ht.AssertNumPendingSweeps(bob, 2)
+ } else {
+ ht.AssertNumPendingSweeps(bob, 1)
+ }
+
+ // We expect to see tow txns in the mempool,
+ // 1. Bob's force close tx.
+ // 2. Bob's anchor sweep tx.
+ ht.AssertNumTxsInMempool(2)
+
+ // Mine a block to confirm the closing tx and the anchor sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // At this point, Bob should have canceled backwards the dust HTLC that
+ // we sent earlier. This means Alice should now only have a single HTLC
+ // on her channel.
+ ht.AssertNumActiveHtlcs(alice, 1)
+
+ // With the closing transaction confirmed, we should expect Bob's HTLC
+ // timeout transaction to be offered to the sweeper due to the expiry
+ // being reached. we also expect Carol's anchor sweeps.
+ ht.AssertNumPendingSweeps(bob, 1)
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Bob's sweeper should sweep his outgoing HTLC immediately since it's
+ // expired. His to_local output cannot be swept due to the CSV lock.
+ // Carol's anchor sweep should be failed due to output being dust.
+ ht.AssertNumTxsInMempool(1)
+
+ // Mine a block to confirm Bob's outgoing HTLC sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // With Bob's HTLC timeout transaction confirmed, there should be no
+ // active HTLC's on the commitment transaction from Alice -> Bob.
+ ht.AssertNumActiveHtlcs(alice, 0)
+
+ // At this point, Bob should show that the pending HTLC has advanced to
+ // the second stage and is ready to be swept once the timelock is up.
+ resp := ht.AssertNumPendingForceClose(bob, 1)[0]
+ require.NotZero(ht, resp.LimboBalance)
+ require.Positive(ht, resp.BlocksTilMaturity)
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+ require.Equal(ht, uint32(2), resp.PendingHtlcs[0].Stage)
+
+ ht.Logf("Bob's timelock to_local output=%v, timelock on second stage "+
+ "htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ if params.CommitmentType == leasedType {
+ // Since Bob is the initiator of the script-enforced leased
+ // channel between him and Carol, he will incur an additional
+ // CLTV on top of the usual CSV delay on any outputs that he
+ // can sweep back to his wallet.
+ //
+ // We now mine enough blocks so the CLTV lock expires, which
+ // will trigger the sweep of the to_local and outgoing HTLC
+ // outputs.
+ ht.MineBlocks(int(resp.BlocksTilMaturity))
+
+ // Check that Bob has a pending sweeping tx which sweeps his
+ // to_local and outgoing HTLC outputs.
+ ht.AssertNumPendingSweeps(bob, 2)
+
+ // Mine a block to confirm the sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+ } else {
+ // Since Bob force closed the channel between him and Carol, he
+ // will incur the usual CSV delay on any outputs that he can
+ // sweep back to his wallet. We'll subtract one block from our
+ // current maturity period to assert on the mempool.
+ ht.MineBlocks(int(resp.BlocksTilMaturity - 1))
+
+ // Check that Bob has a pending sweeping tx which sweeps his
+ // to_local output.
+ ht.AssertNumPendingSweeps(bob, 1)
+
+ // Mine a block to confirm the to_local sweeping tx, which also
+ // triggers the sweeping of the second stage HTLC output.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // Bob's sweeper should now broadcast his second layer sweep
+ // due to the CSV on the HTLC timeout output.
+ ht.AssertNumTxsInMempool(1)
+
+ // Next, we'll mine a final block that should confirm the
+ // sweeping transactions left.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+ }
+
+ // Once this transaction has been confirmed, Bob should detect that he
+ // no longer has any pending channels.
+ ht.AssertNumPendingForceClose(bob, 0)
+}
+
+// testMultiHopReceiverPreimageClaimAnchor tests
+// `runMultiHopReceiverPreimageClaim` with anchor channels.
+func testMultiHopReceiverPreimageClaimAnchor(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // anchor channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{Amt: chanAmt}
+
+ cfg := node.CfgAnchor
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runMultiHopReceiverPreimageClaim(st, cfgs, openChannelParams)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: lnrpc.CommitmentType_ANCHORS,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and anchor.
+ cfg := node.CfgZeroConf
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runMultiHopReceiverPreimageClaim(st, cfgs, openChannelParams)
+ })
+}
+
+// testMultiHopReceiverPreimageClaimSimpleTaproot tests
+// `runMultiHopReceiverPreimageClaim` with simple taproot channels.
+func testMultiHopReceiverPreimageClaimSimpleTaproot(ht *lntest.HarnessTest) {
+ c := lnrpc.CommitmentType_SIMPLE_TAPROOT
+
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // simple taproot channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ cfg := node.CfgSimpleTaproot
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runMultiHopReceiverPreimageClaim(st, cfgs, openChannelParams)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf simple taproot channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgSimpleTaproot
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runMultiHopReceiverPreimageClaim(st, cfgs, openChannelParams)
+ })
+}
+
+// testMultiHopReceiverPreimageClaimLeased tests
+// `runMultiHopReceiverPreimageClaim` with script enforce lease channels.
+func testMultiHopReceiverPreimageClaimLeased(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // leased channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: leasedType,
+ }
+
+ cfg := node.CfgLeased
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runMultiHopReceiverPreimageClaim(st, cfgs, openChannelParams)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ openChannelParams := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: leasedType,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgLeased
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runMultiHopReceiverPreimageClaim(st, cfgs, openChannelParams)
+ })
+}
+
+// runMultiHopReceiverClaim tests that in the multi-hop setting, if the
+// receiver of an HTLC knows the preimage, but wasn't able to settle the HTLC
+// off-chain, then it goes on chain to claim the HTLC uing the HTLC success
+// transaction. In this scenario, the node that sent the outgoing HTLC should
+// extract the preimage from the sweep transaction, and finish settling the
+// HTLC backwards into the route.
+func runMultiHopReceiverPreimageClaim(ht *lntest.HarnessTest,
+ cfgs [][]string, params lntest.OpenChannelParams) {
+
+ // Set the min relay feerate to be 10 sat/vbyte so the non-CPFP anchor
+ // is never swept.
+ //
+ // TODO(yy): delete this line once the normal anchor sweeping is
+ // removed.
+ ht.SetMinRelayFeerate(10_000)
+
+ // Create a three hop network: Alice -> Bob -> Carol.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ bobChanPoint := chanPoints[1]
+
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
+
+ // For neutrino backend, we need to one more UTXO for Carol so she can
+ // sweep her outputs.
+ if ht.IsNeutrinoBackend() {
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
+ }
+
+ // Fund Carol one UTXO so she can sweep outputs.
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
+
+ // If this is a taproot channel, then we'll need to make some manual
+ // route hints so Alice can actually find a route.
+ var routeHints []*lnrpc.RouteHint
+ if params.CommitmentType == lnrpc.CommitmentType_SIMPLE_TAPROOT {
+ routeHints = makeRouteHints(bob, carol, params.ZeroConf)
+ }
+
+ // With the network active, we'll now add a new hodl invoice at Carol's
+ // end. Make sure the cltv expiry delta is large enough, otherwise Bob
+ // won't send out the outgoing htlc.
+ var preimage lntypes.Preimage
+ copy(preimage[:], ht.Random32Bytes())
+ payHash := preimage.Hash()
+
+ invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
+ Value: invoiceAmt,
+ CltvExpiry: finalCltvDelta,
+ Hash: payHash[:],
+ RouteHints: routeHints,
+ }
+ carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
+
+ // Subscribe the invoice.
+ stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
+
+ // Now that we've created the invoice, we'll send a single payment from
+ // Alice to Carol. We won't wait for the response however, as Carol
+ // will not immediately settle the payment.
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: carolInvoice.PaymentRequest,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLC pending on all of them.
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLCs pending on all of them.
+ //
+ // Alice should have one outgoing HTLCs on channel Alice -> Bob.
+ ht.AssertNumActiveHtlcs(alice, 1)
+
+ // Bob should have one incoming HTLC on channel Alice -> Bob, and one
+ // outgoing HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(bob, 2)
+
+ // Carol should have one incoming HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(carol, 1)
+
+ // Wait for Carol to mark invoice as accepted. There is a small gap to
+ // bridge between adding the htlc to the channel and executing the exit
+ // hop logic.
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
+
+ // Stop Bob so he won't be able to settle the incoming htlc.
+ restartBob := ht.SuspendNode(bob)
+
+ // Settle invoice. This will just mark the invoice as settled, as there
+ // is no link anymore to remove the htlc from the commitment tx. For
+ // this test, it is important to actually settle and not leave the
+ // invoice in the accepted state, because without a known preimage, the
+ // channel arbitrator won't go to chain.
+ carol.RPC.SettleInvoice(preimage[:])
+
+ // We now advance the block height to the point where Carol will force
+ // close her channel with Bob, broadcast the closing tx but keep it
+ // unconfirmed.
+ numBlocks := padCLTV(uint32(
+ invoiceReq.CltvExpiry - incomingBroadcastDelta,
+ ))
+
+ // Now we'll mine enough blocks to prompt Carol to actually go to the
+ // chain in order to sweep her HTLC since the value is high enough.
+ ht.MineBlocks(int(numBlocks))
+
+ // Carol's force close tx should have the following outputs,
+ // 1. anchor output.
+ // 2. to_local output, which is CSV locked.
+ // 3. incoming HTLC output, which she has the preimage to settle.
+ //
+ // Carol's anchor output should be offered to her sweeper since she has
+ // time-sensitive HTLCs - we expect both anchors to be offered, while
+ // the sweeping of the remote anchor will be marked as failed due to
+ // `testmempoolaccept` check.
+ //
+ // For neutrino backend, there's no way to know the sweeping of the
+ // remote anchor is failed, so Carol still sees two pending sweeps.
+ if ht.IsNeutrinoBackend() {
+ ht.AssertNumPendingSweeps(carol, 2)
+ } else {
+ ht.AssertNumPendingSweeps(carol, 1)
+ }
+
+ // We expect to see tow txns in the mempool,
+ // 1. Carol's force close tx.
+ // 2. Carol's anchor sweep tx.
+ ht.AssertNumTxsInMempool(2)
+
+ // Mine a block to confirm the closing tx and the anchor sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ ht.Log("Current height", ht.CurrentHeight())
+
+ // After the force close tx is mined, Carol should offer her second
+ // level HTLC tx to the sweeper.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Restart bob again.
+ require.NoError(ht, restartBob())
+
+ // Once Bob is online, he should notice Carol's second level tx in the
+ // mempool, he will extract the preimage and settle the HTLC back
+ // off-chain. He will also try to sweep his anchor and to_local
+ // outputs, with the anchor output being skipped due to it being
+ // uneconomical.
+ if params.CommitmentType == leasedType {
+ // For leased channels, Bob cannot sweep his to_local output
+ // yet since it's timelocked, so we only see his anchor input.
+ ht.AssertNumPendingSweeps(bob, 1)
+ } else {
+ // For non-leased channels, Bob should have two pending sweeps,
+ // 1. to_local output.
+ // 2. anchor output, tho it won't be swept due to it being
+ // uneconomical.
+ ht.AssertNumPendingSweeps(bob, 2)
+ }
+
+ // Mine an empty block the for neutrino backend. We need this step to
+ // trigger Bob's chain watcher to detect the force close tx. Deep down,
+ // this happens because the notification system for neutrino is very
+ // different from others. Specifically, when a block contains the force
+ // close tx is notified, these two calls,
+ // - RegisterBlockEpochNtfn, will notify the block first.
+ // - RegisterSpendNtfn, will wait for the neutrino notifier to sync to
+ // the block, then perform a GetUtxo, which, by the time the spend
+ // details are sent, the blockbeat is considered processed in Bob's
+ // chain watcher.
+ //
+ // TODO(yy): refactor txNotifier to fix the above issue.
+ if ht.IsNeutrinoBackend() {
+ ht.MineEmptyBlocks(1)
+ }
+
+ if params.CommitmentType == leasedType {
+ // We expect to see 1 txns in the mempool,
+ // - Carol's second level HTLC sweep tx.
+ // We now mine a block to confirm it.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+ } else {
+ // We expect to see 2 txns in the mempool,
+ // - Bob's to_local sweep tx.
+ // - Carol's second level HTLC sweep tx.
+ // We now mine a block to confirm the sweeping txns.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+ }
+
+ // Once the second-level transaction confirmed, Bob should have
+ // extracted the preimage from the chain, and sent it back to Alice,
+ // clearing the HTLC off-chain.
+ ht.AssertNumActiveHtlcs(alice, 0)
+
+ // Check that the Alice's payment is correctly marked succeeded.
+ ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
+
+ // Carol's pending channel report should now show two outputs under
+ // limbo: her commitment output, as well as the second-layer claim
+ // output, and the pending HTLC should also now be in stage 2.
+ ht.AssertNumHTLCsAndStage(carol, bobChanPoint, 1, 2)
+
+ // If we mine 4 additional blocks, then Carol can sweep the second
+ // level HTLC output once the CSV expires.
+ ht.MineBlocks(defaultCSV - 1)
+
+ // Assert Carol has the pending HTLC sweep.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // We should have a new transaction in the mempool.
+ ht.AssertNumTxsInMempool(1)
+
+ // Finally, if we mine an additional block to confirm Carol's second
+ // level success transaction. Carol should not show a pending channel
+ // in her report afterwards.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+ ht.AssertNumPendingForceClose(carol, 0)
+
+ // The invoice should show as settled for Carol, indicating that it was
+ // swept on-chain.
+ ht.AssertInvoiceSettled(carol, carolInvoice.PaymentAddr)
+
+ // For leased channels, Bob still has his commit output to sweep to
+ // since he incurred an additional CLTV from being the channel
+ // initiator.
+ if params.CommitmentType == leasedType {
+ resp := ht.AssertNumPendingForceClose(bob, 1)[0]
+ require.Positive(ht, resp.LimboBalance)
+ require.Positive(ht, resp.BlocksTilMaturity)
+
+ // Mine enough blocks for Bob's commit output's CLTV to expire
+ // and sweep it.
+ ht.MineBlocks(int(resp.BlocksTilMaturity))
+
+ // Bob should have two pending inputs to be swept, the commit
+ // output and the anchor output.
+ ht.AssertNumPendingSweeps(bob, 2)
+
+ // Mine a block to confirm the commit output sweep.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+ }
+
+ // Assert Bob also sees the channel as closed.
+ ht.AssertNumPendingForceClose(bob, 0)
+}
+
+// testLocalForceCloseBeforeTimeoutAnchor tests
+// `runLocalForceCloseBeforeHtlcTimeout` with anchor channel.
+func testLocalForceCloseBeforeTimeoutAnchor(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{Amt: chanAmt}
+
+ cfg := node.CfgAnchor
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: lnrpc.CommitmentType_ANCHORS,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and anchor.
+ cfg := node.CfgZeroConf
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+}
+
+// testLocalForceCloseBeforeTimeoutSimpleTaproot tests
+// `runLocalForceCloseBeforeHtlcTimeout` with simple taproot channel.
+func testLocalForceCloseBeforeTimeoutSimpleTaproot(ht *lntest.HarnessTest) {
+ c := lnrpc.CommitmentType_SIMPLE_TAPROOT
+
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // simple taproot channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ cfg := node.CfgSimpleTaproot
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf simple taproot channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgSimpleTaproot
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+}
+
+// testLocalForceCloseBeforeTimeoutLeased tests
+// `runLocalForceCloseBeforeHtlcTimeout` with script enforced lease channel.
+func testLocalForceCloseBeforeTimeoutLeased(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // leased channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: leasedType,
+ }
+
+ cfg := node.CfgLeased
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: leasedType,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgLeased
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runLocalForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+}
+
+// runLocalForceCloseBeforeHtlcTimeout tests that in a multi-hop HTLC scenario,
+// if the node that extended the HTLC to the final node closes their commitment
+// on-chain early, then it eventually recognizes this HTLC as one that's timed
+// out. At this point, the node should timeout the HTLC using the HTLC timeout
+// transaction, then cancel it backwards as normal.
+func runLocalForceCloseBeforeHtlcTimeout(ht *lntest.HarnessTest,
+ cfgs [][]string, params lntest.OpenChannelParams) {
+
+ // Set the min relay feerate to be 10 sat/vbyte so the non-CPFP anchor
+ // is never swept.
+ //
+ // TODO(yy): delete this line once the normal anchor sweeping is
+ // removed.
+ ht.SetMinRelayFeerate(10_000)
+
+ // Create a three hop network: Alice -> Bob -> Carol.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ bobChanPoint := chanPoints[1]
+
+ // With our channels set up, we'll then send a single HTLC from Alice
+ // to Carol. As Carol is in hodl mode, she won't settle this HTLC which
+ // opens up the base for out tests.
+
+ // If this is a taproot channel, then we'll need to make some manual
+ // route hints so Alice can actually find a route.
+ var routeHints []*lnrpc.RouteHint
+ if params.CommitmentType == lnrpc.CommitmentType_SIMPLE_TAPROOT {
+ routeHints = makeRouteHints(bob, carol, params.ZeroConf)
+ }
+
+ // We'll now send a single HTLC across our multi-hop network.
+ carolPubKey := carol.PubKey[:]
+ payHash := ht.Random32Bytes()
+ req := &routerrpc.SendPaymentRequest{
+ Dest: carolPubKey,
+ Amt: int64(htlcAmt),
+ PaymentHash: payHash,
+ FinalCltvDelta: finalCltvDelta,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ RouteHints: routeHints,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLC pending on all of them.
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLCs pending on all of them.
+ //
+ // Alice should have one outgoing HTLC on channel Alice -> Bob.
+ ht.AssertNumActiveHtlcs(alice, 1)
+
+ // Bob should have one incoming HTLC on channel Alice -> Bob, and one
+ // outgoing HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(bob, 2)
+
+ // Carol should have one incoming HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(carol, 1)
+
+ // Now that all parties have the HTLC locked in, we'll immediately
+ // force close the Bob -> Carol channel. This should trigger contract
+ // resolution mode for both of them.
+ stream, _ := ht.CloseChannelAssertPending(bob, bobChanPoint, true)
+ ht.AssertStreamChannelForceClosed(bob, bobChanPoint, true, stream)
+
+ // Bob's force close tx should have the following outputs,
+ // 1. anchor output.
+ // 2. to_local output, which is CSV locked.
+ // 3. outgoing HTLC output, which hasn't expired yet.
+ //
+ // The channel close has anchors, we should expect to see both Bob and
+ // Carol has a pending sweep request for the anchor sweep.
+ ht.AssertNumPendingSweeps(carol, 1)
+ anchorSweep := ht.AssertNumPendingSweeps(bob, 1)[0]
+
+ // We expcet Bob's anchor sweep to be a non-CPFP anchor sweep now.
+ // Although he has time-sensitive outputs, which means initially his
+ // anchor output was used for CPFP, this anchor will be replaced by a
+ // new anchor sweeping request once his force close tx is confirmed in
+ // the above block. The timeline goes as follows:
+ // 1. At block 447, Bob force closes his channel with Carol, which
+ // caused the channel arbitartor to create a CPFP anchor sweep.
+ // 2. This force close tx was mined in AssertStreamChannelForceClosed,
+ // and we are now in block 448.
+ // 3. Since the blockbeat is processed via the chain [ChainArbitrator
+ // -> chainWatcher -> channelArbitrator -> Sweeper -> TxPublisher],
+ // when it reaches `chainWatcher`, Bob will detect the confirmed
+ // force close tx and notifies `channelArbitrator`. In response,
+ // `channelArbitrator` will advance to `StateContractClosed`, in
+ // which it will prepare an anchor resolution that's non-CPFP, send
+ // it to the sweeper to replace the CPFP anchor sweep.
+ // 4. By the time block 448 reaches `Sweeper`, the old CPFP anchor
+ // sweep has already been replaced with the new non-CPFP anchor
+ // sweep.
+ require.EqualValues(ht, 330, anchorSweep.Budget, "expected 330 sat "+
+ "budget, got %v", anchorSweep.Budget)
+
+ // Before the HTLC times out, we'll need to assert that Bob broadcasts
+ // a sweep tx for his commit output. Note that if the channel has a
+ // script-enforced lease, then Bob will have to wait for an additional
+ // CLTV before sweeping it.
+ if params.CommitmentType != leasedType {
+ // The sweeping tx is broadcast on the block CSV-1 so mine one
+ // block less than defaultCSV in order to perform mempool
+ // assertions.
+ ht.MineBlocks(int(defaultCSV - 1))
+
+ // Mine a block to confirm Bob's to_local sweep.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+ }
+
+ // We'll now mine enough blocks for the HTLC to expire. After this, Bob
+ // should hand off the now expired HTLC output to the sweeper.
+ resp := ht.AssertNumPendingForceClose(bob, 1)[0]
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+
+ ht.Logf("Bob's timelock to_local output=%v, timelock on second stage "+
+ "htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ ht.MineBlocks(int(resp.PendingHtlcs[0].BlocksTilMaturity))
+
+ // Bob's pending channel report should show that he has a single HTLC
+ // that's now in stage one.
+ ht.AssertNumHTLCsAndStage(bob, bobChanPoint, 1, 1)
+
+ // Bob should have two pending sweep requests,
+ // 1. the anchor sweep.
+ // 2. the outgoing HTLC sweep.
+ ht.AssertNumPendingSweeps(bob, 2)
+
+ // Bob's outgoing HTLC sweep should be broadcast now. Mine a block to
+ // confirm it.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // With the second layer timeout tx confirmed, Bob should have canceled
+ // backwards the HTLC that Carol sent.
+ ht.AssertNumActiveHtlcs(bob, 0)
+
+ // Additionally, Bob should now show that HTLC as being advanced to the
+ // second stage.
+ ht.AssertNumHTLCsAndStage(bob, bobChanPoint, 1, 2)
+
+ // Get the expiry height of the CSV-locked HTLC.
+ resp = ht.AssertNumPendingForceClose(bob, 1)[0]
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+ pendingHtlc := resp.PendingHtlcs[0]
+ require.Positive(ht, pendingHtlc.BlocksTilMaturity)
+
+ ht.Logf("Bob's timelock to_local output=%v, timelock on second stage "+
+ "htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ // Mine enough blocks for the HTLC to expire.
+ ht.MineBlocks(int(pendingHtlc.BlocksTilMaturity))
+
+ // Based on this is a leased channel or not, Bob may still need to
+ // sweep his to_local output.
+ if params.CommitmentType == leasedType {
+ // Bob should have three pending sweep requests,
+ // 1. the anchor sweep.
+ // 2. the second-level HTLC sweep.
+ // 3. the to_local output sweep, which is CSV+CLTV locked, is
+ // now mature.
+ //
+ // The test is setup such that the to_local and the
+ // second-level HTLC sweeps share the same deadline, which
+ // means they will be swept in the same tx.
+ ht.AssertNumPendingSweeps(bob, 3)
+ } else {
+ // Bob should have two pending sweeps,
+ // 1. the anchor sweep.
+ // 2. the second-level HTLC sweep.
+ ht.AssertNumPendingSweeps(bob, 2)
+ }
+
+ // Now that the CSV timelock has expired, mine a block to confirm the
+ // sweep.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // At this point, Bob should no longer show any channels as pending
+ // close.
+ ht.AssertNumPendingForceClose(bob, 0)
+}
+
+// testRemoteForceCloseBeforeTimeoutAnchor tests
+// `runRemoteForceCloseBeforeHtlcTimeout` with anchor channel.
+func testRemoteForceCloseBeforeTimeoutAnchor(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{Amt: chanAmt}
+
+ cfg := node.CfgAnchor
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runRemoteForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: lnrpc.CommitmentType_ANCHORS,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and anchor.
+ cfg := node.CfgZeroConf
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runRemoteForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+}
+
+// testRemoteForceCloseBeforeTimeoutSimpleTaproot tests
+// `runLocalForceCloseBeforeHtlcTimeout` with simple taproot channel.
+func testRemoteForceCloseBeforeTimeoutSimpleTaproot(ht *lntest.HarnessTest) {
+ c := lnrpc.CommitmentType_SIMPLE_TAPROOT
+
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // simple taproot channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ cfg := node.CfgSimpleTaproot
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runRemoteForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf simple taproot channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgSimpleTaproot
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runRemoteForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+}
+
+// testRemoteForceCloseBeforeTimeoutLeased tests
+// `runRemoteForceCloseBeforeHtlcTimeout` with script enforced lease channel.
+func testRemoteForceCloseBeforeTimeoutLeased(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // leased channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: leasedType,
+ }
+
+ cfg := node.CfgLeased
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runRemoteForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: leasedType,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgLeased
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgCarol := append([]string{"--hodl.exit-settle"}, cfg...)
+ cfgs := [][]string{cfg, cfg, cfgCarol}
+
+ runRemoteForceCloseBeforeHtlcTimeout(st, cfgs, params)
+ })
+}
+
+// runRemoteForceCloseBeforeHtlcTimeout tests that if we extend a multi-hop
+// HTLC, and the final destination of the HTLC force closes the channel, then
+// we properly timeout the HTLC directly on *their* commitment transaction once
+// the timeout has expired. Once we sweep the transaction, we should also
+// cancel back the initial HTLC.
+func runRemoteForceCloseBeforeHtlcTimeout(ht *lntest.HarnessTest,
+ cfgs [][]string, params lntest.OpenChannelParams) {
+
+ // Set the min relay feerate to be 10 sat/vbyte so the non-CPFP anchor
+ // is never swept.
+ //
+ // TODO(yy): delete this line once the normal anchor sweeping is
+ // removed.
+ ht.SetMinRelayFeerate(10_000)
+
+ // Create a three hop network: Alice -> Bob -> Carol.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ bobChanPoint := chanPoints[1]
+
+ // If this is a taproot channel, then we'll need to make some manual
+ // route hints so Alice can actually find a route.
+ var routeHints []*lnrpc.RouteHint
+ if params.CommitmentType == lnrpc.CommitmentType_SIMPLE_TAPROOT {
+ routeHints = makeRouteHints(bob, carol, params.ZeroConf)
+ }
+
+ // With our channels set up, we'll then send a single HTLC from Alice
+ // to Carol. As Carol is in hodl mode, she won't settle this HTLC which
+ // opens up the base for out tests.
+ var preimage lntypes.Preimage
+ copy(preimage[:], ht.Random32Bytes())
+ payHash := preimage.Hash()
+ invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
+ Value: int64(htlcAmt),
+ CltvExpiry: finalCltvDelta,
+ Hash: payHash[:],
+ RouteHints: routeHints,
+ }
+ carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
+
+ // Subscribe the invoice.
+ stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
+
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: carolInvoice.PaymentRequest,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLCs pending on all of them.
+ //
+ // Alice should have one outgoing HTLC on channel Alice -> Bob.
+ ht.AssertNumActiveHtlcs(alice, 1)
+
+ // Bob should have one incoming HTLC on channel Alice -> Bob, and one
+ // outgoing HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(bob, 2)
+
+ // Carol should have one incoming HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(carol, 1)
+
+ // At this point, we'll now instruct Carol to force close the tx. This
+ // will let us exercise that Bob is able to sweep the expired HTLC on
+ // Carol's version of the commitment tx.
+ closeStream, _ := ht.CloseChannelAssertPending(
+ carol, bobChanPoint, true,
+ )
+
+ // For anchor channels, the anchor won't be used for CPFP because
+ // channel arbitrator thinks Carol doesn't have preimage for her
+ // incoming HTLC on the commitment transaction Bob->Carol. Although
+ // Carol created this invoice, because it's a hold invoice, the
+ // preimage won't be generated automatically.
+ ht.AssertStreamChannelForceClosed(
+ carol, bobChanPoint, true, closeStream,
+ )
+
+ // At this point, Bob should have a pending force close channel as
+ // Carol has gone directly to chain.
+ ht.AssertNumPendingForceClose(bob, 1)
+
+ // Carol will offer her anchor to her sweeper.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Bob should offered the anchor output to his sweeper.
+ if params.CommitmentType == leasedType {
+ // For script enforced lease channels, Bob can sweep his anchor
+ // output immediately although it will be skipped due to it
+ // being uneconomical. His to_local output is CLTV locked so it
+ // cannot be swept yet.
+ ht.AssertNumPendingSweeps(bob, 1)
+ } else {
+ // For non-leased channels, Bob can sweep his commit and anchor
+ // outputs immediately.
+ ht.AssertNumPendingSweeps(bob, 2)
+
+ // We expect to see only one sweeping tx to be published from
+ // Bob, which sweeps his to_local output. His anchor output
+ // won't be swept due it being uneconomical. For Carol, since
+ // her anchor is not used for CPFP, it'd be also uneconomical
+ // to sweep so it will fail.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+ }
+
+ // Next, we'll mine enough blocks for the HTLC to expire. At this
+ // point, Bob should hand off the output to his sweeper, which will
+ // broadcast a sweep transaction.
+ resp := ht.AssertNumPendingForceClose(bob, 1)[0]
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+
+ ht.Logf("Bob's timelock to_local output=%v, timelock on second stage "+
+ "htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ ht.MineBlocks(int(resp.PendingHtlcs[0].BlocksTilMaturity))
+
+ // If we check Bob's pending channel report, it should show that he has
+ // a single HTLC that's now in the second stage, as it skipped the
+ // initial first stage since this is a direct HTLC.
+ ht.AssertNumHTLCsAndStage(bob, bobChanPoint, 1, 2)
+
+ // Bob should have two pending sweep requests,
+ // 1. the uneconomical anchor sweep.
+ // 2. the direct timeout sweep.
+ ht.AssertNumPendingSweeps(bob, 2)
+
+ // Bob's sweeping tx should now be found in the mempool.
+ sweepTx := ht.AssertNumTxsInMempool(1)[0]
+
+ // If we mine an additional block, then this should confirm Bob's tx
+ // which sweeps the direct HTLC output.
+ block := ht.MineBlocksAndAssertNumTxes(1, 1)[0]
+ ht.AssertTxInBlock(block, sweepTx)
+
+ // Now that the sweeping tx has been confirmed, Bob should cancel back
+ // that HTLC. As a result, Alice should not know of any active HTLC's.
+ ht.AssertNumActiveHtlcs(alice, 0)
+
+ // For script enforced lease channels, Bob still need to wait for the
+ // CLTV lock to expire before he can sweep his to_local output.
+ if params.CommitmentType == leasedType {
+ // Get the remaining blocks to mine.
+ resp = ht.AssertNumPendingForceClose(bob, 1)[0]
+ ht.MineBlocks(int(resp.BlocksTilMaturity))
+
+ // Assert the commit output has been offered to the sweeper.
+ // Bob should have two pending sweep requests - one for the
+ // commit output and one for the anchor output.
+ ht.AssertNumPendingSweeps(bob, 2)
+
+ // Mine the to_local sweep tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+ }
+
+ // Now we'll check Bob's pending channel report. Since this was Carol's
+ // commitment, he doesn't have to wait for any CSV delays, but he may
+ // still need to wait for a CLTV on his commit output to expire
+ // depending on the commitment type.
+ ht.AssertNumPendingForceClose(bob, 0)
+
+ // While we're here, we assert that our expired invoice's state is
+ // correctly updated, and can no longer be settled.
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_CANCELED)
+}
+
+// testLocalClaimIncomingHTLCAnchor tests `runLocalClaimIncomingHTLC` with
+// anchor channel.
+func testLocalClaimIncomingHTLCAnchor(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{Amt: chanAmt}
+
+ cfg := node.CfgAnchor
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalClaimIncomingHTLC(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: lnrpc.CommitmentType_ANCHORS,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and anchor.
+ cfg := node.CfgZeroConf
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalClaimIncomingHTLC(st, cfgs, params)
+ })
+}
+
+// testLocalClaimIncomingHTLCSimpleTaproot tests `runLocalClaimIncomingHTLC`
+// with simple taproot channel.
+func testLocalClaimIncomingHTLCSimpleTaproot(ht *lntest.HarnessTest) {
+ c := lnrpc.CommitmentType_SIMPLE_TAPROOT
+
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // simple taproot channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ cfg := node.CfgSimpleTaproot
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalClaimIncomingHTLC(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf simple taproot channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgSimpleTaproot
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalClaimIncomingHTLC(st, cfgs, params)
+ })
+}
+
+// runLocalClaimIncomingHTLC tests that in a multi-hop HTLC scenario, if we
+// force close a channel with an incoming HTLC, and later find out the preimage
+// via the witness beacon, we properly settle the HTLC on-chain using the HTLC
+// success transaction in order to ensure we don't lose any funds.
+func runLocalClaimIncomingHTLC(ht *lntest.HarnessTest,
+ cfgs [][]string, params lntest.OpenChannelParams) {
+
+ // Set the min relay feerate to be 10 sat/vbyte so the non-CPFP anchor
+ // is never swept.
+ //
+ // TODO(yy): delete this line once the normal anchor sweeping is
+ // removed.
+ ht.SetMinRelayFeerate(10_000)
+
+ // Create a three hop network: Alice -> Bob -> Carol.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ aliceChanPoint := chanPoints[0]
+
+ // Fund Carol one UTXO so she can sweep outputs.
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
+
+ // If this is a taproot channel, then we'll need to make some manual
+ // route hints so Alice can actually find a route.
+ var routeHints []*lnrpc.RouteHint
+ if params.CommitmentType == lnrpc.CommitmentType_SIMPLE_TAPROOT {
+ routeHints = makeRouteHints(bob, carol, params.ZeroConf)
+ }
+
+ // With the network active, we'll now add a new hodl invoice at Carol's
+ // end. Make sure the cltv expiry delta is large enough, otherwise Bob
+ // won't send out the outgoing htlc.
+ preimage := ht.RandomPreimage()
+ payHash := preimage.Hash()
+
+ invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
+ Value: invoiceAmt,
+ CltvExpiry: finalCltvDelta,
+ Hash: payHash[:],
+ RouteHints: routeHints,
+ }
+ carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
+
+ // Subscribe the invoice.
+ stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
+
+ // Now that we've created the invoice, we'll send a single payment from
+ // Alice to Carol. We won't wait for the response however, as Carol
+ // will not immediately settle the payment.
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: carolInvoice.PaymentRequest,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLC pending on all of them.
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLCs pending on all of them.
+ //
+ // Alice should have one outgoing HTLC on channel Alice -> Bob.
+ ht.AssertNumActiveHtlcs(alice, 1)
+
+ // Bob should have one incoming HTLC on channel Alice -> Bob, and one
+ // outgoing HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(bob, 2)
+
+ // Carol should have one incoming HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(carol, 1)
+
+ // Wait for carol to mark invoice as accepted. There is a small gap to
+ // bridge between adding the htlc to the channel and executing the exit
+ // hop logic.
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
+
+ // At this point, Bob decides that he wants to exit the channel
+ // Alice=>Bob immediately, so he force closes his commitment tx.
+ closeStream, _ := ht.CloseChannelAssertPending(
+ bob, aliceChanPoint, true,
+ )
+
+ // For anchor channels, the anchor won't be used for CPFP as there's no
+ // deadline pressure for Bob on the channel Alice->Bob at the moment.
+ // For Bob's local commitment tx, there's only one incoming HTLC which
+ // he doesn't have the preimage yet.
+ hasAnchorSweep := false
+ bobForceClose := ht.AssertStreamChannelForceClosed(
+ bob, aliceChanPoint, hasAnchorSweep, closeStream,
+ )
+
+ // Alice will offer her to_local and anchor outputs to her sweeper.
+ ht.AssertNumPendingSweeps(alice, 2)
+
+ // Bob will offer his anchor to his sweeper.
+ ht.AssertNumPendingSweeps(bob, 1)
+
+ // Assert the expected num of txns are found in the mempool.
+ //
+ // We expect to see only one sweeping tx to be published from Alice,
+ // which sweeps her to_local output (which is to to_remote on Bob's
+ // commit tx). Her anchor output won't be swept as it's uneconomical.
+ // For Bob, since his anchor is not used for CPFP, it'd be uneconomical
+ // to sweep so it will fail.
+ ht.AssertNumTxsInMempool(1)
+
+ // Mine a block to confirm Alice's sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // Suspend Bob to force Carol to go to chain.
+ restartBob := ht.SuspendNode(bob)
+
+ // Settle invoice. This will just mark the invoice as settled, as there
+ // is no link anymore to remove the htlc from the commitment tx. For
+ // this test, it is important to actually settle and not leave the
+ // invoice in the accepted state, because without a known preimage, the
+ // channel arbitrator won't go to chain.
+ carol.RPC.SettleInvoice(preimage[:])
+
+ // We now advance the block height to the point where Carol will force
+ // close her channel with Bob, broadcast the closing tx but keep it
+ // unconfirmed.
+ numBlocks := padCLTV(
+ uint32(invoiceReq.CltvExpiry - incomingBroadcastDelta),
+ )
+
+ // We've already mined 2 blocks at this point, so we only need to mine
+ // CLTV-2 blocks.
+ ht.MineBlocks(int(numBlocks - 2))
+
+ // Expect two txns in the mempool,
+ // - Carol's force close tx.
+ // - Carol's CPFP anchor sweeping tx.
+ // Mine a block to confirm them.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // After the force close tx is mined, Carol should offer her
+ // second-level success HTLC tx to her sweeper.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Restart bob again.
+ require.NoError(ht, restartBob())
+
+ // Once Bob is online and sees the force close tx Bob=>Carol, he will
+ // create a tx to sweep his commitment output. His anchor outputs will
+ // not be swept due to uneconomical. We expect to see three sweeping
+ // requests,
+ // - the commitment output.
+ // - the anchor output from channel Alice=>Bob.
+ // - the anchor output from channel Bob=>Carol.
+ ht.AssertNumPendingSweeps(bob, 3)
+
+ // Mine an empty block the for neutrino backend. We need this step to
+ // trigger Bob's chain watcher to detect the force close tx. Deep down,
+ // this happens because the notification system for neutrino is very
+ // different from others. Specifically, when a block contains the force
+ // close tx is notified, these two calls,
+ // - RegisterBlockEpochNtfn, will notify the block first.
+ // - RegisterSpendNtfn, will wait for the neutrino notifier to sync to
+ // the block, then perform a GetUtxo, which, by the time the spend
+ // details are sent, the blockbeat is considered processed in Bob's
+ // chain watcher.
+ //
+ // TODO(yy): refactor txNotifier to fix the above issue.
+ if ht.IsNeutrinoBackend() {
+ ht.MineEmptyBlocks(1)
+ }
+
+ // Assert txns can be found in the mempool.
+ //
+ // Carol will broadcast her sweeping tx and Bob will sweep his
+ // commitment anchor output, we'd expect to see two txns,
+ // - Carol's second level HTLC tx.
+ // - Bob's commitment output sweeping tx.
+ ht.AssertNumTxsInMempool(2)
+
+ // At this point we suspend Alice to make sure she'll handle the
+ // on-chain settle after a restart.
+ restartAlice := ht.SuspendNode(alice)
+
+ // Mine a block to confirm the sweeping txns made by Bob and Carol.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // When Bob notices Carol's second level tx in the block, he will
+ // extract the preimage and broadcast a second level tx to claim the
+ // HTLC in his (already closed) channel with Alice, which means Bob has
+ // three sweeping requests,
+ // - the second level HTLC tx from channel Alice=>Bob.
+ // - the anchor output from channel Alice=>Bob.
+ // - the anchor output from channel Bob=>Carol.
+ ht.AssertNumPendingSweeps(bob, 3)
+
+ // Mine a block to trigger the sweep. This is needed because the
+ // preimage extraction logic from the link is not managed by the
+ // blockbeat, which means the preimage may be sent to the contest
+ // resolver after it's launched.
+ //
+ // TODO(yy): Expose blockbeat to the link layer.
+ ht.MineEmptyBlocks(1)
+
+ // At this point, Bob should have broadcast his second layer success
+ // tx, and should have sent it to his sweeper.
+ //
+ // Check Bob's second level tx.
+ bobSecondLvlTx := ht.GetNumTxsFromMempool(1)[0]
+
+ // It should spend from the commitment in the channel with Alice.
+ ht.AssertTxSpendFrom(bobSecondLvlTx, bobForceClose)
+
+ // We'll now mine a block which should confirm Bob's second layer tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // Bob should consider the channel Bob=>Carol closed, and channel
+ // Alice=>Bob pending close.
+ ht.AssertNumPendingForceClose(bob, 1)
+
+ // Now that the preimage from Bob has hit the chain, restart Alice to
+ // ensure she'll pick it up.
+ require.NoError(ht, restartAlice())
+
+ // If we then mine 1 additional block, Carol's second level tx should
+ // mature, and she can pull the funds from it with a sweep tx.
+ resp := ht.AssertNumPendingForceClose(carol, 1)[0]
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+
+ ht.Logf("Carol's timelock to_local output=%v, timelock on second "+
+ "stage htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ ht.MineBlocks(int(resp.PendingHtlcs[0].BlocksTilMaturity))
+
+ // Carol should have one a sweep request for her second level tx.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Carol's sweep tx should be broadcast, assert it's in the mempool and
+ // mine it.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // We now mine blocks till the CSV lock on Bob's success HTLC on
+ // commitment Alice=>Bob expires.
+ resp = ht.AssertNumPendingForceClose(bob, 1)[0]
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+
+ ht.Logf("Bob's timelock to_local output=%v, timelock on second stage "+
+ "htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ ht.MineBlocks(int(resp.PendingHtlcs[0].BlocksTilMaturity))
+
+ // Bob should have three requests in his sweeper.
+ // - the second level HTLC tx.
+ // - the anchor output from channel Alice=>Bob.
+ // - the anchor output from channel Bob=>Carol.
+ ht.AssertNumPendingSweeps(bob, 3)
+
+ // When we mine one additional block, that will confirm Bob's sweep.
+ // Now Bob should have no pending channels anymore, as this just
+ // resolved it by the confirmation of the sweep transaction.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // All nodes should show zero pending and open channels.
+ for _, node := range []*node.HarnessNode{alice, bob, carol} {
+ ht.AssertNumPendingForceClose(node, 0)
+ ht.AssertNodeNumChannels(node, 0)
+ }
+
+ // Finally, check that the Alice's payment is correctly marked
+ // succeeded.
+ ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
+}
+
+// testLocalClaimIncomingHTLCLeased tests `runLocalClaimIncomingHTLCLeased`
+// with script enforced lease channel.
+func testLocalClaimIncomingHTLCLeased(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // leased channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: leasedType,
+ }
+
+ cfg := node.CfgLeased
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalClaimIncomingHTLCLeased(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: leasedType,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgLeased
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalClaimIncomingHTLCLeased(st, cfgs, params)
+ })
+}
+
+// runLocalClaimIncomingHTLCLeased tests that in a multi-hop HTLC scenario, if
+// we force close a channel with an incoming HTLC, and later find out the
+// preimage via the witness beacon, we properly settle the HTLC on-chain using
+// the HTLC success transaction in order to ensure we don't lose any funds.
+//
+// TODO(yy): simplify or remove this test as it's too complicated.
+func runLocalClaimIncomingHTLCLeased(ht *lntest.HarnessTest,
+ cfgs [][]string, params lntest.OpenChannelParams) {
+
+ // Set the min relay feerate to be 5 sat/vbyte so the non-CPFP anchor
+ // is never swept.
+ //
+ // TODO(yy): delete this line once the normal anchor sweeping is
+ // removed.
+ ht.SetMinRelayFeerate(5000)
+
+ // Create a three hop network: Alice -> Bob -> Carol.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ aliceChanPoint, bobChanPoint := chanPoints[0], chanPoints[1]
+
+ // Fund Carol one UTXO so she can sweep outputs.
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
+
+ // Carol should have enough wallet UTXOs here to sweep the HTLC in the
+ // end of this test. However, due to a known issue, Carol's wallet may
+ // report there's no UTXO available. For details,
+ // - https://github.com/lightningnetwork/lnd/issues/8786
+ //
+ // TODO(yy): remove this step once the issue is resolved.
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
+
+ // With the network active, we'll now add a new hodl invoice at Carol's
+ // end. Make sure the cltv expiry delta is large enough, otherwise Bob
+ // won't send out the outgoing htlc.
+ preimage := ht.RandomPreimage()
+ payHash := preimage.Hash()
+
+ invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
+ Value: invoiceAmt,
+ CltvExpiry: finalCltvDelta,
+ Hash: payHash[:],
+ }
+ carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
+
+ // Subscribe the invoice.
+ stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
+
+ // Now that we've created the invoice, we'll send a single payment from
+ // Alice to Carol. We won't wait for the response however, as Carol
+ // will not immediately settle the payment.
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: carolInvoice.PaymentRequest,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLC pending on all of them.
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLCs pending on all of them.
+ //
+ // Alice should have one outgoing HTLC on channel Alice -> Bob.
+ ht.AssertNumActiveHtlcs(alice, 1)
+
+ // Bob should have one incoming HTLC on channel Alice -> Bob, and one
+ // outgoing HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(bob, 2)
+
+ // Carol should have one incoming HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(carol, 1)
+
+ // Wait for carol to mark invoice as accepted. There is a small gap to
+ // bridge between adding the htlc to the channel and executing the exit
+ // hop logic.
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
+
+ // At this point, Bob decides that he wants to exit the channel
+ // Alice=>Bob immediately, so he force closes his commitment tx.
+ closeStream, _ := ht.CloseChannelAssertPending(
+ bob, aliceChanPoint, true,
+ )
+
+ // For anchor channels, the anchor won't be used for CPFP as there's no
+ // deadline pressure for Bob on the channel Alice->Bob at the moment.
+ // For Bob's local commitment tx, there's only one incoming HTLC which
+ // he doesn't have the preimage yet.
+ hasAnchorSweep := false
+ bobForceClose := ht.AssertStreamChannelForceClosed(
+ bob, aliceChanPoint, hasAnchorSweep, closeStream,
+ )
+
+ // Alice will offer her anchor output to her sweeper. Her commitment
+ // output cannot be swept yet as it has incurred an additional CLTV due
+ // to being the initiator of a script-enforced leased channel.
+ //
+ // This anchor output cannot be swept due to it being uneconomical.
+ ht.AssertNumPendingSweeps(alice, 1)
+
+ // Bob will offer his anchor to his sweeper.
+ //
+ // This anchor output cannot be swept due to it being uneconomical.
+ ht.AssertNumPendingSweeps(bob, 1)
+
+ // Suspend Bob to force Carol to go to chain.
+ restartBob := ht.SuspendNode(bob)
+
+ // Settle invoice. This will just mark the invoice as settled, as there
+ // is no link anymore to remove the htlc from the commitment tx. For
+ // this test, it is important to actually settle and not leave the
+ // invoice in the accepted state, because without a known preimage, the
+ // channel arbitrator won't go to chain.
+ carol.RPC.SettleInvoice(preimage[:])
+
+ // We now advance the block height to the point where Carol will force
+ // close her channel with Bob, broadcast the closing tx but keep it
+ // unconfirmed.
+ numBlocks := padCLTV(
+ uint32(invoiceReq.CltvExpiry - incomingBroadcastDelta),
+ )
+ ht.MineBlocks(int(numBlocks) - 1)
+
+ // Expect two txns in the mempool,
+ // - Carol's force close tx.
+ // - Carol's CPFP anchor sweeping tx.
+ // Mine a block to confirm them.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // After the force close tx is mined, Carol should offer her
+ // second-level success HTLC tx to her sweeper.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Restart bob again.
+ require.NoError(ht, restartBob())
+
+ // Once Bob is online and sees the force close tx Bob=>Carol, he will
+ // offer his commitment output to his sweeper, which will be skipped
+ // due to it being timelocked. His anchor outputs will not be swept due
+ // to uneconomical. We expect to see two sweeping requests,
+ // - the anchor output from channel Alice=>Bob.
+ // - the anchor output from channel Bob=>Carol.
+ ht.AssertNumPendingSweeps(bob, 2)
+
+ // Assert txns can be found in the mempool.
+ //
+ // Carol will broadcast her second-level HTLC sweeping txns. Bob canoot
+ // sweep his commitment anchor output yet due to it being CLTV locked.
+ ht.AssertNumTxsInMempool(1)
+
+ // At this point we suspend Alice to make sure she'll handle the
+ // on-chain settle after a restart.
+ restartAlice := ht.SuspendNode(alice)
+
+ // Mine a block to confirm the sweeping tx from Carol.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // When Bob notices Carol's second level tx in the block, he will
+ // extract the preimage and broadcast a second level tx to claim the
+ // HTLC in his (already closed) channel with Alice, which means Bob has
+ // three sweeping requests,
+ // - the second level HTLC tx from channel Alice=>Bob.
+ // - the anchor output from channel Alice=>Bob.
+ // - the anchor output from channel Bob=>Carol.
+ ht.AssertNumPendingSweeps(bob, 3)
+
+ // Mine a block to trigger the sweep. This is needed because the
+ // preimage extraction logic from the link is not managed by the
+ // blockbeat, which means the preimage may be sent to the contest
+ // resolver after it's launched.
+ //
+ // TODO(yy): Expose blockbeat to the link layer.
+ ht.MineEmptyBlocks(1)
+
+ // At this point, Bob should have broadcast his second layer success
+ // tx, and should have sent it to his sweeper.
+ //
+ // Check Bob's second level tx.
+ bobSecondLvlTx := ht.GetNumTxsFromMempool(1)[0]
+
+ // It should spend from the commitment in the channel with Alice.
+ ht.AssertTxSpendFrom(bobSecondLvlTx, bobForceClose)
+
+ // The channel between Bob and Carol will still be pending force close
+ // if this is a leased channel. We'd also check the HTLC stages are
+ // correct in both channels.
+ ht.AssertNumPendingForceClose(bob, 2)
+ ht.AssertNumHTLCsAndStage(bob, aliceChanPoint, 1, 1)
+ ht.AssertNumHTLCsAndStage(bob, bobChanPoint, 1, 1)
+
+ // We'll now mine a block which should confirm Bob's second layer tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // Now that the preimage from Bob has hit the chain, restart Alice to
+ // ensure she'll pick it up.
+ require.NoError(ht, restartAlice())
+
+ // If we then mine 1 additional block, Carol's second level tx should
+ // mature, and she can pull the funds from it with a sweep tx.
+ resp := ht.AssertNumPendingForceClose(carol, 1)[0]
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+
+ ht.Logf("Carol's timelock to_local output=%v, timelock on second "+
+ "stage htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ ht.MineBlocks(int(resp.PendingHtlcs[0].BlocksTilMaturity))
+
+ // Carol should have one a sweep request for her second level tx.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Carol's sweep tx should be broadcast, assert it's in the mempool and
+ // mine it.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // We now mine blocks till the CSV lock on Bob's success HTLC on
+ // commitment Alice=>Bob expires.
+ resp = ht.AssertChannelPendingForceClose(bob, aliceChanPoint)
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+ htlcExpiry := resp.PendingHtlcs[0].BlocksTilMaturity
+
+ ht.Logf("Bob's timelock to_local output=%v, timelock on second stage "+
+ "htlc=%v", resp.BlocksTilMaturity, htlcExpiry)
+ ht.MineBlocks(int(htlcExpiry))
+
+ // When we mine one additional block, that will confirm Bob's second
+ // level HTLC sweep on channel Alice=>Bob.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // We now mine blocks till the CLTV lock on Bob's to_local output HTLC
+ // on commitment Bob=>Carol expires.
+ resp = ht.AssertChannelPendingForceClose(bob, bobChanPoint)
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+ htlcExpiry = resp.PendingHtlcs[0].BlocksTilMaturity
+
+ ht.Logf("Bob's timelock to_local output=%v, timelock on second stage "+
+ "htlc=%v", resp.BlocksTilMaturity, htlcExpiry)
+ ht.MineBlocks(int(resp.BlocksTilMaturity))
+
+ // Bob should have three requests in his sweeper.
+ // - to_local output from channel Bob=>Carol.
+ // - the anchor output from channel Alice=>Bob, uneconomical.
+ // - the anchor output from channel Bob=>Carol, uneconomical.
+ ht.AssertNumPendingSweeps(bob, 3)
+
+ // Alice should have two requests in her sweeper,
+ // - the anchor output from channel Alice=>Bob, uneconomical.
+ // - her commitment output, now mature.
+ ht.AssertNumPendingSweeps(alice, 2)
+
+ // Mine a block to confirm Bob's to_local output sweep.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // All nodes should show zero pending and open channels.
+ for _, node := range []*node.HarnessNode{alice, bob, carol} {
+ ht.AssertNumPendingForceClose(node, 0)
+ ht.AssertNodeNumChannels(node, 0)
+ }
+
+ // Finally, check that the Alice's payment is correctly marked
+ // succeeded.
+ ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
+}
+
+// testLocalPreimageClaimAnchor tests `runLocalPreimageClaim` with anchor
+// channel.
+func testLocalPreimageClaimAnchor(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{Amt: chanAmt}
+
+ cfg := node.CfgAnchor
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalPreimageClaim(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: lnrpc.CommitmentType_ANCHORS,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and anchor.
+ cfg := node.CfgZeroConf
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalPreimageClaim(st, cfgs, params)
+ })
+}
+
+// testLocalPreimageClaimSimpleTaproot tests `runLocalClaimIncomingHTLC` with
+// simple taproot channel.
+func testLocalPreimageClaimSimpleTaproot(ht *lntest.HarnessTest) {
+ c := lnrpc.CommitmentType_SIMPLE_TAPROOT
+
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // simple taproot channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ cfg := node.CfgSimpleTaproot
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalPreimageClaim(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf simple taproot channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgSimpleTaproot
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalPreimageClaim(st, cfgs, params)
+ })
+}
+
+// runLocalPreimageClaim tests that in the multi-hop HTLC scenario, if the
+// remote party goes to chain while we have an incoming HTLC, then when we
+// found out the preimage via the witness beacon, we properly settle the HTLC
+// directly on-chain using the preimage in order to ensure that we don't lose
+// any funds.
+func runLocalPreimageClaim(ht *lntest.HarnessTest,
+ cfgs [][]string, params lntest.OpenChannelParams) {
+
+ // Set the min relay feerate to be 10 sat/vbyte so the non-CPFP anchor
+ // is never swept.
+ //
+ // TODO(yy): delete this line once the normal anchor sweeping is
+ // removed.
+ ht.SetMinRelayFeerate(10_000)
+
+ // Create a three hop network: Alice -> Bob -> Carol.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ aliceChanPoint := chanPoints[0]
+
+ // Fund Carol one UTXO so she can sweep outputs.
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
+
+ // Carol should have enough wallet UTXOs here to sweep the HTLC in the
+ // end of this test. However, due to a known issue, Carol's wallet may
+ // report there's no UTXO available. For details,
+ // - https://github.com/lightningnetwork/lnd/issues/8786
+ //
+ // TODO(yy): remove this step once the issue is resolved.
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
+
+ // If this is a taproot channel, then we'll need to make some manual
+ // route hints so Alice can actually find a route.
+ var routeHints []*lnrpc.RouteHint
+ if params.CommitmentType == lnrpc.CommitmentType_SIMPLE_TAPROOT {
+ routeHints = makeRouteHints(bob, carol, params.ZeroConf)
+ }
+
+ // With the network active, we'll now add a new hodl invoice at Carol's
+ // end. Make sure the cltv expiry delta is large enough, otherwise Bob
+ // won't send out the outgoing htlc.
+ preimage := ht.RandomPreimage()
+ payHash := preimage.Hash()
+
+ invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
+ Value: invoiceAmt,
+ CltvExpiry: finalCltvDelta,
+ Hash: payHash[:],
+ RouteHints: routeHints,
+ }
+ carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
+
+ // Subscribe the invoice.
+ stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
+
+ // Now that we've created the invoice, we'll send a single payment from
+ // Alice to Carol. We won't wait for the response however, as Carol
+ // will not immediately settle the payment.
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: carolInvoice.PaymentRequest,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLCs pending on all of them.
+ //
+ // Alice should have one outgoing HTLC on channel Alice -> Bob.
+ ht.AssertNumActiveHtlcs(alice, 1)
+
+ // Bob should have one incoming HTLC on channel Alice -> Bob, and one
+ // outgoing HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(bob, 2)
+
+ // Carol should have one incoming HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(carol, 1)
+
+ // Wait for carol to mark invoice as accepted. There is a small gap to
+ // bridge between adding the htlc to the channel and executing the exit
+ // hop logic.
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
+
+ // Record the height which the invoice will expire.
+ invoiceExpiry := ht.CurrentHeight() + uint32(invoiceReq.CltvExpiry)
+
+ // Next, Alice decides that she wants to exit the channel, so she'll
+ // immediately force close the channel by broadcast her commitment
+ // transaction.
+ closeStream, _ := ht.CloseChannelAssertPending(
+ alice, aliceChanPoint, true,
+ )
+ aliceForceClose := ht.AssertStreamChannelForceClosed(
+ alice, aliceChanPoint, true, closeStream,
+ )
+
+ // Wait for the channel to be marked pending force close.
+ ht.AssertChannelPendingForceClose(alice, aliceChanPoint)
+
+ // Once the force closing tx is mined, Alice should offer the anchor
+ // output to her sweeper.
+ ht.AssertNumPendingSweeps(alice, 1)
+
+ // Bob should offer his anchor output to his sweeper.
+ ht.AssertNumPendingSweeps(bob, 1)
+
+ // Mine enough blocks for Alice to sweep her funds from the force
+ // closed channel. AssertStreamChannelForceClosed() already mined a
+ // block, so mine one less than defaultCSV in order to perform mempool
+ // assertions.
+ ht.MineBlocks(defaultCSV - 1)
+
+ // Mine Alice's commit sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // Suspend bob, so Carol is forced to go on chain.
+ restartBob := ht.SuspendNode(bob)
+
+ // Settle invoice. This will just mark the invoice as settled, as there
+ // is no link anymore to remove the htlc from the commitment tx. For
+ // this test, it is important to actually settle and not leave the
+ // invoice in the accepted state, because without a known preimage, the
+ // channel arbitrator won't go to chain.
+ carol.RPC.SettleInvoice(preimage[:])
+
+ ht.Logf("Invoice expire height: %d, current: %d", invoiceExpiry,
+ ht.CurrentHeight())
+
+ // We'll now mine enough blocks so Carol decides that she needs to go
+ // on-chain to claim the HTLC as Bob has been inactive.
+ numBlocks := padCLTV(
+ invoiceExpiry - ht.CurrentHeight() - incomingBroadcastDelta,
+ )
+ ht.MineBlocks(int(numBlocks))
+
+ // Since Carol has time-sensitive HTLCs, she will use the anchor for
+ // CPFP purpose. Assert the anchor output is offered to the sweeper.
+ //
+ // For neutrino backend, Carol still have the two anchors - one from
+ // local commitment and the other from the remote.
+ if ht.IsNeutrinoBackend() {
+ ht.AssertNumPendingSweeps(carol, 2)
+ } else {
+ ht.AssertNumPendingSweeps(carol, 1)
+ }
+
+ // We should see two txns in the mempool, we now a block to confirm,
+ // - Carol's force close tx.
+ // - Carol's anchor sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // Once the force close tx is confirmed, Carol should offer her
+ // incoming HTLC to her sweeper.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Restart bob again.
+ require.NoError(ht, restartBob())
+
+ // Bob should have three sweeping requests,
+ // - the anchor output from channel Alice=>Bob, uneconomical.
+ // - the anchor output from channel Bob=>Carol, uneconomical.
+ // - the commit output sweep from the channel with Carol, no timelock.
+ ht.AssertNumPendingSweeps(bob, 3)
+
+ // Mine an empty block the for neutrino backend. We need this step to
+ // trigger Bob's chain watcher to detect the force close tx. Deep down,
+ // this happens because the notification system for neutrino is very
+ // different from others. Specifically, when a block contains the force
+ // close tx is notified, these two calls,
+ // - RegisterBlockEpochNtfn, will notify the block first.
+ // - RegisterSpendNtfn, will wait for the neutrino notifier to sync to
+ // the block, then perform a GetUtxo, which, by the time the spend
+ // details are sent, the blockbeat is considered processed in Bob's
+ // chain watcher.
+ //
+ // TODO(yy): refactor txNotifier to fix the above issue.
+ if ht.IsNeutrinoBackend() {
+ ht.MineEmptyBlocks(1)
+ }
+
+ // We mine one block to confirm,
+ // - Carol's sweeping tx of the incoming HTLC.
+ // - Bob's sweeping tx of his commit output.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // When Bob notices Carol's second level tx in the block, he will
+ // extract the preimage and offer the HTLC to his sweeper. So he has,
+ // - the anchor output from channel Alice=>Bob, uneconomical.
+ // - the anchor output from channel Bob=>Carol, uneconomical.
+ // - the htlc sweeping tx.
+ ht.AssertNumPendingSweeps(bob, 3)
+
+ // Mine an empty block the for neutrino backend. We need this step to
+ // trigger Bob's chain watcher to detect the force close tx. Deep down,
+ // this happens because the notification system for neutrino is very
+ // different from others. Specifically, when a block contains the force
+ // close tx is notified, these two calls,
+ // - RegisterBlockEpochNtfn, will notify the block first.
+ // - RegisterSpendNtfn, will wait for the neutrino notifier to sync to
+ // the block, then perform a GetUtxo, which, by the time the spend
+ // details are sent, the blockbeat is considered processed in Bob's
+ // chain watcher.
+ //
+ // TODO(yy): refactor txNotifier to fix the above issue.
+ if ht.IsNeutrinoBackend() {
+ ht.MineEmptyBlocks(1)
+ }
+
+ // Mine a block to trigger the sweep. This is needed because the
+ // preimage extraction logic from the link is not managed by the
+ // blockbeat, which means the preimage may be sent to the contest
+ // resolver after it's launched.
+ //
+ // TODO(yy): Expose blockbeat to the link layer.
+ ht.MineEmptyBlocks(1)
+
+ // Bob should broadcast the sweeping of the direct preimage spent now.
+ bobHtlcSweep := ht.GetNumTxsFromMempool(1)[0]
+
+ // It should spend from the commitment in the channel with Alice.
+ ht.AssertTxSpendFrom(bobHtlcSweep, aliceForceClose)
+
+ // We'll now mine a block which should confirm Bob's HTLC sweep tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // Now that the sweeping tx has been confirmed, Bob should recognize
+ // that all contracts for the Bob-Carol channel have been fully
+ // resolved.
+ ht.AssertNumPendingForceClose(bob, 0)
+
+ // Mine blocks till Carol's second level tx matures.
+ resp := ht.AssertNumPendingForceClose(carol, 1)[0]
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+
+ ht.Logf("Carol's timelock to_local output=%v, timelock on second "+
+ "stage htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ ht.MineBlocks(int(resp.PendingHtlcs[0].BlocksTilMaturity))
+
+ // Carol should offer the htlc output to her sweeper.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Mine a block to confirm Carol's sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // When Carol's sweep gets confirmed, she should have no more pending
+ // channels.
+ ht.AssertNumPendingForceClose(carol, 0)
+
+ // The invoice should show as settled for Carol, indicating that it was
+ // swept on-chain.
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_SETTLED)
+
+ // Finally, check that the Alice's payment is correctly marked
+ // succeeded.
+ ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
+}
+
+// testLocalPreimageClaimLeased tests `runLocalPreimageClaim` with script
+// enforced lease channel.
+func testLocalPreimageClaimLeased(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // leased channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: leasedType,
+ }
+
+ cfg := node.CfgLeased
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalPreimageClaimLeased(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: leasedType,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgLeased
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runLocalPreimageClaimLeased(st, cfgs, params)
+ })
+}
+
+// runLocalPreimageClaimLeased tests that in the multi-hop HTLC scenario, if
+// the remote party goes to chain while we have an incoming HTLC, then when we
+// found out the preimage via the witness beacon, we properly settle the HTLC
+// directly on-chain using the preimage in order to ensure that we don't lose
+// any funds.
+func runLocalPreimageClaimLeased(ht *lntest.HarnessTest,
+ cfgs [][]string, params lntest.OpenChannelParams) {
+
+ // Set the min relay feerate to be 10 sat/vbyte so the non-CPFP anchor
+ // is never swept.
+ //
+ // TODO(yy): delete this line once the normal anchor sweeping is
+ // removed.
+ ht.SetMinRelayFeerate(10_000)
+
+ // Create a three hop network: Alice -> Bob -> Carol.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ aliceChanPoint, bobChanPoint := chanPoints[0], chanPoints[1]
+
+ // Fund Carol one UTXO so she can sweep outputs.
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
+
+ // With the network active, we'll now add a new hodl invoice at Carol's
+ // end. Make sure the cltv expiry delta is large enough, otherwise Bob
+ // won't send out the outgoing htlc.
+ preimage := ht.RandomPreimage()
+ payHash := preimage.Hash()
+
+ invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
+ Value: invoiceAmt,
+ CltvExpiry: finalCltvDelta,
+ Hash: payHash[:],
+ }
+ carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
+
+ // Subscribe the invoice.
+ stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
+
+ // Now that we've created the invoice, we'll send a single payment from
+ // Alice to Carol. We won't wait for the response however, as Carol
+ // will not immediately settle the payment.
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: carolInvoice.PaymentRequest,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLCs pending on all of them.
+ //
+ // Alice should have one outgoing HTLC on channel Alice -> Bob.
+ ht.AssertNumActiveHtlcs(alice, 1)
+
+ // Bob should have one incoming HTLC on channel Alice -> Bob, and one
+ // outgoing HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(bob, 2)
+
+ // Carol should have one incoming HTLC on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(carol, 1)
+
+ // Wait for carol to mark invoice as accepted. There is a small gap to
+ // bridge between adding the htlc to the channel and executing the exit
+ // hop logic.
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
+
+ // Record the height which the invoice will expire.
+ invoiceExpiry := ht.CurrentHeight() + uint32(invoiceReq.CltvExpiry)
+
+ // Next, Alice decides that she wants to exit the channel, so she'll
+ // immediately force close the channel by broadcast her commitment
+ // transaction.
+ closeStream, _ := ht.CloseChannelAssertPending(
+ alice, aliceChanPoint, true,
+ )
+ aliceForceClose := ht.AssertStreamChannelForceClosed(
+ alice, aliceChanPoint, true, closeStream,
+ )
+
+ // Wait for the channel to be marked pending force close.
+ ht.AssertChannelPendingForceClose(alice, aliceChanPoint)
+
+ // Once the force closing tx is mined, Alice should offer the anchor
+ // output to her sweeper.
+ ht.AssertNumPendingSweeps(alice, 1)
+
+ // Bob should offer his anchor output to his sweeper.
+ ht.AssertNumPendingSweeps(bob, 1)
+
+ // Suspend bob, so Carol is forced to go on chain.
+ restartBob := ht.SuspendNode(bob)
+
+ // Settle invoice. This will just mark the invoice as settled, as there
+ // is no link anymore to remove the htlc from the commitment tx. For
+ // this test, it is important to actually settle and not leave the
+ // invoice in the accepted state, because without a known preimage, the
+ // channel arbitrator won't go to chain.
+ carol.RPC.SettleInvoice(preimage[:])
+
+ ht.Logf("Invoice expire height: %d, current: %d", invoiceExpiry,
+ ht.CurrentHeight())
+
+ // We'll now mine enough blocks so Carol decides that she needs to go
+ // on-chain to claim the HTLC as Bob has been inactive.
+ numBlocks := padCLTV(
+ invoiceExpiry - ht.CurrentHeight() - incomingBroadcastDelta - 1,
+ )
+ ht.MineBlocks(int(numBlocks))
+
+ // Since Carol has time-sensitive HTLCs, she will use the anchor for
+ // CPFP purpose. Assert the anchor output is offered to the sweeper.
+ //
+ // For neutrino backend, there's no way to know the sweeping of the
+ // remote anchor is failed, so Carol still sees two pending sweeps.
+ if ht.IsNeutrinoBackend() {
+ ht.AssertNumPendingSweeps(carol, 2)
+ } else {
+ ht.AssertNumPendingSweeps(carol, 1)
+ }
+
+ // We should see two txns in the mempool, we now a block to confirm,
+ // - Carol's force close tx.
+ // - Carol's anchor sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // Once the force close tx is confirmed, Carol should offer her
+ // incoming HTLC to her sweeper.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Restart bob again.
+ require.NoError(ht, restartBob())
+
+ // Bob should have two sweeping requests,
+ // - the anchor output from channel Alice=>Bob, uneconomical.
+ // - the anchor output from channel Bob=>Carol, uneconomical.
+ // - the commit output sweep from the channel with Carol, which is CLTV
+ // locked so it won't show up the pending sweeps.
+ ht.AssertNumPendingSweeps(bob, 2)
+
+ // We mine one block to confirm,
+ // - Carol's sweeping tx of the incoming HTLC.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // When Bob notices Carol's second level tx in the block, he will
+ // extract the preimage and offer the HTLC to his sweeper. So he has,
+ // - the anchor output from channel Alice=>Bob, uneconomical.
+ // - the anchor output from channel Bob=>Carol, uneconomical.
+ // - the htlc sweeping tx.
+ ht.AssertNumPendingSweeps(bob, 3)
+
+ // Mine a block to trigger the sweep. This is needed because the
+ // preimage extraction logic from the link is not managed by the
+ // blockbeat, which means the preimage may be sent to the contest
+ // resolver after it's launched.
+ //
+ // TODO(yy): Expose blockbeat to the link layer.
+ ht.MineEmptyBlocks(1)
+
+ // Bob should broadcast the sweeping of the direct preimage spent now.
+ bobHtlcSweep := ht.GetNumTxsFromMempool(1)[0]
+
+ // It should spend from the commitment in the channel with Alice.
+ ht.AssertTxSpendFrom(bobHtlcSweep, aliceForceClose)
+
+ // We'll now mine a block which should confirm Bob's HTLC sweep tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // Now that the sweeping tx has been confirmed, Bob should recognize
+ // that all contracts for the Bob-Carol channel have been fully
+ // resolved.
+ ht.AssertNumPendingForceClose(bob, 1)
+ ht.AssertChannelPendingForceClose(bob, bobChanPoint)
+
+ // Mine blocks till Carol's second level tx matures.
+ resp := ht.AssertNumPendingForceClose(carol, 1)[0]
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+
+ ht.Logf("Carol's timelock to_local output=%v, timelock on second "+
+ "stage htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ ht.MineBlocks(int(resp.PendingHtlcs[0].BlocksTilMaturity))
+
+ // Carol should offer the htlc output to her sweeper.
+ ht.AssertNumPendingSweeps(carol, 1)
+
+ // Mine a block to confirm Carol's sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
+ // When Carol's sweep gets confirmed, she should have no more pending
+ // channels.
+ ht.AssertNumPendingForceClose(carol, 0)
+
+ // The invoice should show as settled for Carol, indicating that it was
+ // swept on-chain.
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_SETTLED)
+
+ // Check that the Alice's payment is correctly marked succeeded.
+ ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
+
+ // With the script-enforced lease commitment type, Alice and Bob still
+ // haven't been able to sweep their respective commit outputs due to
+ // the additional CLTV. We'll need to mine enough blocks for the
+ // timelock to expire and prompt their sweep.
+ //
+ // Get num of blocks to mine.
+ resp = ht.AssertNumPendingForceClose(alice, 1)[0]
+ require.Equal(ht, 1, len(resp.PendingHtlcs))
+
+ ht.Logf("Alice's timelock to_local output=%v, timelock on second "+
+ "stage htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ ht.MineBlocks(int(resp.BlocksTilMaturity))
+
+ // Alice should two sweeping requests,
+ // - the anchor output from channel Alice=>Bob, uneconomical.
+ // - the commit output sweep from the channel with Bob.
+ ht.AssertNumPendingSweeps(alice, 2)
+
+ // Bob should have three sweeping requests,
+ // - the anchor output from channel Alice=>Bob, uneconomical.
+ // - the anchor output from channel Bob=>Carol, uneconomical.
+ // - the commit output sweep from the channel with Carol.
+ ht.AssertNumPendingSweeps(bob, 3)
+
+ // Confirm their sweeps.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // Both nodes should consider the channel fully closed.
+ ht.AssertNumPendingForceClose(alice, 0)
+ ht.AssertNumPendingForceClose(bob, 0)
+}
+
+// testHtlcAggregaitonAnchor tests `runHtlcAggregation` with anchor channel.
+func testHtlcAggregaitonAnchor(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{Amt: chanAmt}
+
+ cfg := node.CfgAnchor
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runHtlcAggregation(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: lnrpc.CommitmentType_ANCHORS,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and anchor.
+ cfg := node.CfgZeroConf
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runHtlcAggregation(st, cfgs, params)
+ })
+}
+
+// testHtlcAggregaitonSimpleTaproot tests `runHtlcAggregation` with simple
+// taproot channel.
+func testHtlcAggregaitonSimpleTaproot(ht *lntest.HarnessTest) {
+ c := lnrpc.CommitmentType_SIMPLE_TAPROOT
+
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // simple taproot channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ cfg := node.CfgSimpleTaproot
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runHtlcAggregation(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf simple taproot channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: c,
+ Private: true,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgSimpleTaproot
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runHtlcAggregation(st, cfgs, params)
+ })
+}
+
+// testHtlcAggregaitonLeased tests `runHtlcAggregation` with script enforced
+// lease channel.
+func testHtlcAggregaitonLeased(ht *lntest.HarnessTest) {
+ success := ht.Run("no zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // leased channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ CommitmentType: leasedType,
+ }
+
+ cfg := node.CfgLeased
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runHtlcAggregation(st, cfgs, params)
+ })
+ if !success {
+ return
+ }
+
+ ht.Run("zero conf", func(t *testing.T) {
+ st := ht.Subtest(t)
+
+ // Create a three hop network: Alice -> Bob -> Carol, using
+ // zero-conf anchor channels.
+ //
+ // Prepare params.
+ params := lntest.OpenChannelParams{
+ Amt: chanAmt,
+ ZeroConf: true,
+ CommitmentType: leasedType,
+ }
+
+ // Prepare Carol's node config to enable zero-conf and leased
+ // channel.
+ cfg := node.CfgLeased
+ cfg = append(cfg, node.CfgZeroConf...)
+ cfgs := [][]string{cfg, cfg, cfg}
+
+ runHtlcAggregation(st, cfgs, params)
+ })
+}
+
+// runHtlcAggregation tests that in a multi-hop HTLC scenario, if we force
+// close a channel with both incoming and outgoing HTLCs, we can properly
+// resolve them using the second level timeout and success transactions. In
+// case of anchor channels, the second-level spends can also be aggregated and
+// properly feebumped, so we'll check that as well.
+func runHtlcAggregation(ht *lntest.HarnessTest,
+ cfgs [][]string, params lntest.OpenChannelParams) {
+
+ // Set the min relay feerate to be 10 sat/vbyte so the non-CPFP anchor
+ // is never swept.
+ //
+ // TODO(yy): delete this line once the normal anchor sweeping is
+ // removed.
+ ht.SetMinRelayFeerate(10_000)
+
+ // Create a three hop network: Alice -> Bob -> Carol.
+ chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params)
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ _, bobChanPoint := chanPoints[0], chanPoints[1]
+
+ // We need one additional UTXO to create the sweeping tx for the
+ // second-level success txes.
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, bob)
+
+ // Bob should have enough wallet UTXOs here to sweep the HTLC in the
+ // end of this test. However, due to a known issue, Bob's wallet may
+ // report there's no UTXO available. For details,
+ // - https://github.com/lightningnetwork/lnd/issues/8786
+ //
+ // TODO(yy): remove this step once the issue is resolved.
+ ht.FundCoins(btcutil.SatoshiPerBitcoin, bob)
+
+ // If this is a taproot channel, then we'll need to make some manual
+ // route hints so Alice+Carol can actually find a route.
+ var (
+ carolRouteHints []*lnrpc.RouteHint
+ aliceRouteHints []*lnrpc.RouteHint
+ )
+
+ if params.CommitmentType == lnrpc.CommitmentType_SIMPLE_TAPROOT {
+ carolRouteHints = makeRouteHints(bob, carol, params.ZeroConf)
+ aliceRouteHints = makeRouteHints(bob, alice, params.ZeroConf)
+ }
+
+ // To ensure we have capacity in both directions of the route, we'll
+ // make a fairly large payment Alice->Carol and settle it.
+ const reBalanceAmt = 500_000
+ invoice := &lnrpc.Invoice{
+ Value: reBalanceAmt,
+ RouteHints: carolRouteHints,
+ }
+ invResp := carol.RPC.AddInvoice(invoice)
+ ht.CompletePaymentRequests(alice, []string{invResp.PaymentRequest})
+
+ // Make sure Carol has settled the invoice.
+ ht.AssertInvoiceSettled(carol, invResp.PaymentAddr)
+
+ // With the network active, we'll now add a new hodl invoices at both
+ // Alice's and Carol's end. Make sure the cltv expiry delta is large
+ // enough, otherwise Bob won't send out the outgoing htlc.
+ const numInvoices = 5
+ const invoiceAmt = 50_000
+
+ var (
+ carolInvoices []*invoicesrpc.AddHoldInvoiceResp
+ aliceInvoices []*invoicesrpc.AddHoldInvoiceResp
+ alicePreimages []lntypes.Preimage
+ payHashes [][]byte
+ invoiceStreamsCarol []rpc.SingleInvoiceClient
+ invoiceStreamsAlice []rpc.SingleInvoiceClient
+ )
+
+ // Add Carol invoices.
+ for i := 0; i < numInvoices; i++ {
+ preimage := ht.RandomPreimage()
+ payHash := preimage.Hash()
+
+ invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
+ Value: invoiceAmt,
+ CltvExpiry: finalCltvDelta,
+ Hash: payHash[:],
+ RouteHints: carolRouteHints,
+ }
+ carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
+
+ carolInvoices = append(carolInvoices, carolInvoice)
+ payHashes = append(payHashes, payHash[:])
+
+ // Subscribe the invoice.
+ stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
+ invoiceStreamsCarol = append(invoiceStreamsCarol, stream)
+ }
+
+ // We'll give Alice's invoices a longer CLTV expiry, to ensure the
+ // channel Bob<->Carol will be closed first.
+ for i := 0; i < numInvoices; i++ {
+ preimage := ht.RandomPreimage()
+ payHash := preimage.Hash()
+
+ invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
+ Value: invoiceAmt,
+ CltvExpiry: thawHeightDelta - 4,
+ Hash: payHash[:],
+ RouteHints: aliceRouteHints,
+ }
+ aliceInvoice := alice.RPC.AddHoldInvoice(invoiceReq)
+
+ aliceInvoices = append(aliceInvoices, aliceInvoice)
+ alicePreimages = append(alicePreimages, preimage)
+ payHashes = append(payHashes, payHash[:])
+
+ // Subscribe the invoice.
+ stream := alice.RPC.SubscribeSingleInvoice(payHash[:])
+ invoiceStreamsAlice = append(invoiceStreamsAlice, stream)
+ }
+
+ // Now that we've created the invoices, we'll pay them all from
+ // Alice<->Carol, going through Bob. We won't wait for the response
+ // however, as neither will immediately settle the payment.
+ //
+ // Alice will pay all of Carol's invoices.
+ for _, carolInvoice := range carolInvoices {
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: carolInvoice.PaymentRequest,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertInflight(alice, req)
+ }
+
+ // And Carol will pay Alice's.
+ for _, aliceInvoice := range aliceInvoices {
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: aliceInvoice.PaymentRequest,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertInflight(carol, req)
+ }
+
+ // At this point, all 3 nodes should now have an active channel with
+ // the created HTLCs pending on all of them.
+ //
+ // Alice sent numInvoices and received numInvoices payments, she should
+ // have numInvoices*2 HTLCs.
+ ht.AssertNumActiveHtlcs(alice, numInvoices*2)
+
+ // Bob should have 2*numInvoices HTLCs on channel Alice -> Bob, and
+ // numInvoices*2 HTLCs on channel Bob -> Carol.
+ ht.AssertNumActiveHtlcs(bob, numInvoices*4)
+
+ // Carol sent numInvoices and received numInvoices payments, she should
+ // have numInvoices*2 HTLCs.
+ ht.AssertNumActiveHtlcs(carol, numInvoices*2)
+
+ // Wait for Alice and Carol to mark the invoices as accepted. There is
+ // a small gap to bridge between adding the htlc to the channel and
+ // executing the exit hop logic.
+ for _, stream := range invoiceStreamsCarol {
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
+ }
+
+ for _, stream := range invoiceStreamsAlice {
+ ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
+ }
+
+ // We want Carol's htlcs to expire off-chain to demonstrate bob's force
+ // close. However, Carol will cancel her invoices to prevent force
+ // closes, so we shut her down for now.
+ restartCarol := ht.SuspendNode(carol)
+
+ // We'll now mine enough blocks to trigger Bob's broadcast of his
+ // commitment transaction due to the fact that the Carol's HTLCs are
+ // about to timeout. With the default outgoing broadcast delta of zero,
+ // this will be the same height as the htlc expiry height.
+ numBlocks := padCLTV(
+ uint32(finalCltvDelta - lncfg.DefaultOutgoingBroadcastDelta),
+ )
+ ht.MineBlocks(int(numBlocks))
+
+ // Bob should have one anchor sweep request.
+ //
+ // For neutrino backend, there's no way to know the sweeping of the
+ // remote anchor is failed, so Bob still sees two pending sweeps.
+ if ht.IsNeutrinoBackend() {
+ ht.AssertNumPendingSweeps(bob, 2)
+ } else {
+ ht.AssertNumPendingSweeps(bob, 1)
+ }
+
+ // Bob's force close tx and anchor sweeping tx should now be found in
+ // the mempool.
+ ht.AssertNumTxsInMempool(2)
+
+ // Mine a block to confirm Bob's force close tx and anchor sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 2)
+
+ // Bob should have `numInvoices` for HTLC timeout txns.
+ ht.AssertNumPendingSweeps(bob, numInvoices)
+
+ // Once bob has force closed, we can restart carol.
+ require.NoError(ht, restartCarol())
+
+ // Carol should have commit and anchor outputs.
+ ht.AssertNumPendingSweeps(carol, 2)
+
+ // Let Alice settle her invoices. When Bob now gets the preimages, he
+ // will broadcast his second-level txns to claim the htlcs.
+ for _, preimage := range alicePreimages {
+ alice.RPC.SettleInvoice(preimage[:])
+ }
+
+ // Bob should have `numInvoices` for both HTLC success and timeout
+ // txns.
+ ht.AssertNumPendingSweeps(bob, numInvoices*2)
+
+ // Mine a block to trigger the sweep. This is needed because the
+ // preimage extraction logic from the link is not managed by the
+ // blockbeat, which means the preimage may be sent to the contest
+ // resolver after it's launched.
+ //
+ // TODO(yy): Expose blockbeat to the link layer.
+ ht.MineEmptyBlocks(1)
+
+ // We expect to see three sweeping txns:
+ // 1. Bob's sweeping tx for all timeout HTLCs.
+ // 2. Bob's sweeping tx for all success HTLCs.
+ // 3. Carol's sweeping tx for her commit output.
+ // Mine a block to confirm them.
+ ht.MineBlocksAndAssertNumTxes(1, 3)
+
+ // For this channel, we also check the number of HTLCs and the stage
+ // are correct.
+ ht.AssertNumHTLCsAndStage(bob, bobChanPoint, numInvoices*2, 2)
+
+ // For non-leased channels, we can now mine one block so Bob will sweep
+ // his to_local output.
+ if params.CommitmentType != leasedType {
+ // Mine one block so Bob's to_local becomes mature.
+ ht.MineBlocks(1)
+
+ // Bob should offer the to_local output to his sweeper now.
+ ht.AssertNumPendingSweeps(bob, 1)
+
+ // Mine a block to confirm Bob's sweeping of his to_local
+ // output.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+ }
+
+ // Mine blocks till the CSV expires on Bob's HTLC output.
+ resp := ht.AssertNumPendingForceClose(bob, 1)[0]
+ require.Equal(ht, numInvoices*2, len(resp.PendingHtlcs))
+
+ ht.Logf("Bob's timelock to_local output=%v, timelock on second stage "+
+ "htlc=%v", resp.BlocksTilMaturity,
+ resp.PendingHtlcs[0].BlocksTilMaturity)
+
+ ht.MineBlocks(int(resp.PendingHtlcs[0].BlocksTilMaturity))
+
+ // With the above mined block, Bob's HTLCs should now all be offered to
+ // his sweeper since the CSV lock is now expired.
+ //
+ // For leased channel, due to the test setup, Bob's to_local output is
+ // now also mature and can be swept together with his HTLCs.
+ if params.CommitmentType == leasedType {
+ ht.AssertNumPendingSweeps(bob, numInvoices*2+1)
+ } else {
+ ht.AssertNumPendingSweeps(bob, numInvoices*2)
+ }
+
+ // When we mine one additional block, that will confirm Bob's second
+ // level sweep. Now Bob should have no pending channels anymore, as
+ // this just resolved it by the confirmation of the sweep tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+ ht.AssertNumPendingForceClose(bob, 0)
+
+ // Carol should have no channels left.
+ ht.AssertNumPendingForceClose(carol, 0)
+}
diff --git a/itest/lnd_multi-hop_test.go b/itest/lnd_multi-hop_test.go
deleted file mode 100644
index f3a906c141d..00000000000
--- a/itest/lnd_multi-hop_test.go
+++ /dev/null
@@ -1,2697 +0,0 @@
-package itest
-
-import (
- "context"
- "fmt"
- "testing"
-
- "github.com/btcsuite/btcd/btcutil"
- "github.com/btcsuite/btcd/chaincfg/chainhash"
- "github.com/btcsuite/btcd/wire"
- "github.com/lightningnetwork/lnd/chainreg"
- "github.com/lightningnetwork/lnd/lncfg"
- "github.com/lightningnetwork/lnd/lnrpc"
- "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
- "github.com/lightningnetwork/lnd/lnrpc/routerrpc"
- "github.com/lightningnetwork/lnd/lntest"
- "github.com/lightningnetwork/lnd/lntest/node"
- "github.com/lightningnetwork/lnd/lntest/rpc"
- "github.com/lightningnetwork/lnd/lntest/wait"
- "github.com/lightningnetwork/lnd/lntypes"
- "github.com/lightningnetwork/lnd/lnwallet/chainfee"
- "github.com/lightningnetwork/lnd/routing"
- "github.com/stretchr/testify/require"
-)
-
-const (
- finalCltvDelta = routing.MinCLTVDelta // 18.
- thawHeightDelta = finalCltvDelta * 2 // 36.
-)
-
-var commitWithZeroConf = []struct {
- commitType lnrpc.CommitmentType
- zeroConf bool
-}{
- {
- commitType: lnrpc.CommitmentType_ANCHORS,
- zeroConf: false,
- },
- {
- commitType: lnrpc.CommitmentType_ANCHORS,
- zeroConf: true,
- },
- {
- commitType: lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE,
- zeroConf: false,
- },
- {
- commitType: lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE,
- zeroConf: true,
- },
- {
- commitType: lnrpc.CommitmentType_SIMPLE_TAPROOT,
- zeroConf: false,
- },
- {
- commitType: lnrpc.CommitmentType_SIMPLE_TAPROOT,
- zeroConf: true,
- },
-}
-
-// makeRouteHints creates a route hints that will allow Carol to be reached
-// using an unadvertised channel created by Bob (Bob -> Carol). If the zeroConf
-// bool is set, then the scid alias of Bob will be used in place.
-func makeRouteHints(bob, carol *node.HarnessNode,
- zeroConf bool) []*lnrpc.RouteHint {
-
- carolChans := carol.RPC.ListChannels(
- &lnrpc.ListChannelsRequest{},
- )
-
- carolChan := carolChans.Channels[0]
-
- hopHint := &lnrpc.HopHint{
- NodeId: carolChan.RemotePubkey,
- ChanId: carolChan.ChanId,
- FeeBaseMsat: uint32(
- chainreg.DefaultBitcoinBaseFeeMSat,
- ),
- FeeProportionalMillionths: uint32(
- chainreg.DefaultBitcoinFeeRate,
- ),
- CltvExpiryDelta: chainreg.DefaultBitcoinTimeLockDelta,
- }
-
- if zeroConf {
- bobChans := bob.RPC.ListChannels(
- &lnrpc.ListChannelsRequest{},
- )
-
- // Now that we have Bob's channels, scan for the channel he has
- // open to Carol so we can use the proper scid.
- var found bool
- for _, bobChan := range bobChans.Channels {
- if bobChan.RemotePubkey == carol.PubKeyStr {
- hopHint.ChanId = bobChan.AliasScids[0]
-
- found = true
-
- break
- }
- }
- if !found {
- bob.Fatalf("unable to create route hint")
- }
- }
-
- return []*lnrpc.RouteHint{
- {
- HopHints: []*lnrpc.HopHint{hopHint},
- },
- }
-}
-
-// caseRunner defines a single test case runner.
-type caseRunner func(ht *lntest.HarnessTest, alice, bob *node.HarnessNode,
- c lnrpc.CommitmentType, zeroConf bool)
-
-// runMultiHopHtlcClaimTest is a helper method to build test cases based on
-// different commitment types and zero-conf config and run them.
-//
-// TODO(yy): flatten this test.
-func runMultiHopHtlcClaimTest(ht *lntest.HarnessTest, tester caseRunner) {
- for _, typeAndConf := range commitWithZeroConf {
- typeAndConf := typeAndConf
- name := fmt.Sprintf("zeroconf=%v/committype=%v",
- typeAndConf.zeroConf, typeAndConf.commitType.String())
-
- // Create the nodes here so that separate logs will be created
- // for Alice and Bob.
- args := lntest.NodeArgsForCommitType(typeAndConf.commitType)
- if typeAndConf.zeroConf {
- args = append(
- args, "--protocol.option-scid-alias",
- "--protocol.zero-conf",
- )
- }
-
- s := ht.Run(name, func(t1 *testing.T) {
- st := ht.Subtest(t1)
-
- alice := st.NewNode("Alice", args)
- bob := st.NewNode("Bob", args)
- st.ConnectNodes(alice, bob)
-
- // Start each test with the default static fee estimate.
- st.SetFeeEstimate(12500)
-
- // Add test name to the logs.
- alice.AddToLogf("Running test case: %s", name)
- bob.AddToLogf("Running test case: %s", name)
-
- tester(
- st, alice, bob,
- typeAndConf.commitType, typeAndConf.zeroConf,
- )
- })
- if !s {
- return
- }
- }
-}
-
-// testMultiHopHtlcLocalTimeout tests that in a multi-hop HTLC scenario, if the
-// outgoing HTLC is about to time out, then we'll go to chain in order to claim
-// it using the HTLC timeout transaction. Any dust HTLC's should be immediately
-// canceled backwards. Once the timeout has been reached, then we should sweep
-// it on-chain, and cancel the HTLC backwards.
-func testMultiHopHtlcLocalTimeout(ht *lntest.HarnessTest) {
- runMultiHopHtlcClaimTest(ht, runMultiHopHtlcLocalTimeout)
-}
-
-func runMultiHopHtlcLocalTimeout(ht *lntest.HarnessTest,
- alice, bob *node.HarnessNode, c lnrpc.CommitmentType, zeroConf bool) {
-
- // First, we'll create a three hop network: Alice -> Bob -> Carol, with
- // Carol refusing to actually settle or directly cancel any HTLC's
- // self.
- aliceChanPoint, bobChanPoint, carol := createThreeHopNetwork(
- ht, alice, bob, true, c, zeroConf,
- )
-
- // For neutrino backend, we need to fund one more UTXO for Bob so he
- // can sweep his outputs.
- if ht.IsNeutrinoBackend() {
- ht.FundCoins(btcutil.SatoshiPerBitcoin, bob)
- }
-
- // Now that our channels are set up, we'll send two HTLC's from Alice
- // to Carol. The first HTLC will be universally considered "dust",
- // while the second will be a proper fully valued HTLC.
- const (
- dustHtlcAmt = btcutil.Amount(100)
- htlcAmt = btcutil.Amount(300_000)
- )
-
- // We'll create two random payment hashes unknown to carol, then send
- // each of them by manually specifying the HTLC details.
- carolPubKey := carol.PubKey[:]
- dustPayHash := ht.Random32Bytes()
- payHash := ht.Random32Bytes()
-
- // If this is a taproot channel, then we'll need to make some manual
- // route hints so Alice can actually find a route.
- var routeHints []*lnrpc.RouteHint
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- routeHints = makeRouteHints(bob, carol, zeroConf)
- }
-
- alice.RPC.SendPayment(&routerrpc.SendPaymentRequest{
- Dest: carolPubKey,
- Amt: int64(dustHtlcAmt),
- PaymentHash: dustPayHash,
- FinalCltvDelta: finalCltvDelta,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- RouteHints: routeHints,
- })
-
- alice.RPC.SendPayment(&routerrpc.SendPaymentRequest{
- Dest: carolPubKey,
- Amt: int64(htlcAmt),
- PaymentHash: payHash,
- FinalCltvDelta: finalCltvDelta,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- RouteHints: routeHints,
- })
-
- // Verify that all nodes in the path now have two HTLC's with the
- // proper parameters.
- ht.AssertActiveHtlcs(alice, dustPayHash, payHash)
- ht.AssertActiveHtlcs(bob, dustPayHash, payHash)
- ht.AssertActiveHtlcs(carol, dustPayHash, payHash)
-
- // Increase the fee estimate so that the following force close tx will
- // be cpfp'ed.
- ht.SetFeeEstimate(30000)
-
- // We'll now mine enough blocks to trigger Bob's broadcast of his
- // commitment transaction due to the fact that the HTLC is about to
- // timeout. With the default outgoing broadcast delta of zero, this will
- // be the same height as the htlc expiry height.
- numBlocks := padCLTV(
- uint32(finalCltvDelta - lncfg.DefaultOutgoingBroadcastDelta),
- )
- ht.MineBlocks(int(numBlocks))
-
- // Bob's force close transaction should now be found in the mempool.
- ht.AssertNumTxsInMempool(1)
- op := ht.OutPointFromChannelPoint(bobChanPoint)
- closeTx := ht.AssertOutpointInMempool(op)
-
- // Dust HTLCs are immediately canceled backwards as soon as the local
- // commitment tx is successfully broadcasted to the local mempool.
- ht.AssertActiveHtlcs(alice, payHash)
-
- // Bob's anchor output should be offered to his sweep since Bob has
- // time-sensitive HTLCs - we expect both anchors are offered.
- ht.AssertNumPendingSweeps(bob, 2)
-
- // Mine a block to confirm the closing transaction.
- ht.MineBlocksAndAssertNumTxes(1, 1)
-
- // With the closing transaction confirmed, we should expect Bob's HTLC
- // timeout transaction to be offered to the sweeper due to the expiry
- // being reached. we also expect Bon and Carol's anchor sweeps.
- ht.AssertNumPendingSweeps(bob, 2)
- ht.AssertNumPendingSweeps(carol, 1)
-
- // Mine a block to trigger Bob's sweeper to sweep.
- ht.MineEmptyBlocks(1)
-
- // The above mined block would trigger Bob and Carol's sweepers to take
- // action. We now expect two txns:
- // 1. Bob's sweeping tx anchor sweep should now be found in the mempool.
- // 2. Bob's HTLC timeout tx sweep should now be found in the mempool.
- // Carol's anchor sweep should be failed due to output being dust.
- ht.AssertNumTxsInMempool(2)
-
- htlcOutpoint := wire.OutPoint{Hash: closeTx.TxHash(), Index: 2}
- commitOutpoint := wire.OutPoint{Hash: closeTx.TxHash(), Index: 3}
- htlcTimeoutTxid := ht.AssertOutpointInMempool(
- htlcOutpoint,
- ).TxHash()
-
- // Mine a block to confirm the above two sweeping txns.
- ht.MineBlocksAndAssertNumTxes(1, 2)
-
- // With Bob's HTLC timeout transaction confirmed, there should be no
- // active HTLC's on the commitment transaction from Alice -> Bob.
- ht.AssertNumActiveHtlcs(alice, 0)
-
- // At this point, Bob should show that the pending HTLC has advanced to
- // the second stage and is ready to be swept once the timelock is up.
- pendingChanResp := bob.RPC.PendingChannels()
- require.Equal(ht, 1, len(pendingChanResp.PendingForceClosingChannels))
- forceCloseChan := pendingChanResp.PendingForceClosingChannels[0]
- require.NotZero(ht, forceCloseChan.LimboBalance)
- require.Positive(ht, forceCloseChan.BlocksTilMaturity)
- require.Equal(ht, 1, len(forceCloseChan.PendingHtlcs))
- require.Equal(ht, uint32(2), forceCloseChan.PendingHtlcs[0].Stage)
-
- ht.Logf("Bob's timelock on commit=%v, timelock on htlc=%v",
- forceCloseChan.BlocksTilMaturity,
- forceCloseChan.PendingHtlcs[0].BlocksTilMaturity)
-
- htlcTimeoutOutpoint := wire.OutPoint{Hash: htlcTimeoutTxid, Index: 0}
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- // Since Bob is the initiator of the script-enforced leased
- // channel between him and Carol, he will incur an additional
- // CLTV on top of the usual CSV delay on any outputs that he
- // can sweep back to his wallet.
- blocksTilMaturity := int(forceCloseChan.BlocksTilMaturity)
-
- // We now mine enough blocks to trigger the sweep of the HTLC
- // timeout tx.
- ht.MineEmptyBlocks(blocksTilMaturity - 1)
-
- // Check that Bob has one pending sweeping tx - the HTLC
- // timeout tx.
- ht.AssertNumPendingSweeps(bob, 1)
-
- // Mine one more blocks, then his commit output will mature.
- // This will also trigger the sweeper to sweep his HTLC timeout
- // tx.
- ht.MineEmptyBlocks(1)
-
- // Check that Bob has two pending sweeping txns.
- ht.AssertNumPendingSweeps(bob, 2)
-
- // Assert that the HTLC timeout tx is now in the mempool.
- ht.AssertOutpointInMempool(htlcTimeoutOutpoint)
-
- // We now wait for 30 seconds to overcome the flake - there's a
- // block race between contractcourt and sweeper, causing the
- // sweep to be broadcast earlier.
- //
- // TODO(yy): remove this once `blockbeat` is in place.
- numExpected := 1
- err := wait.NoError(func() error {
- mem := ht.GetRawMempool()
- if len(mem) == 2 {
- numExpected = 2
- return nil
- }
-
- return fmt.Errorf("want %d, got %v in mempool: %v",
- numExpected, len(mem), mem)
- }, wait.DefaultTimeout)
- ht.Logf("Checking mempool got: %v", err)
-
- // Mine a block to trigger the sweep of his commit output and
- // confirm his HTLC timeout sweep.
- ht.MineBlocksAndAssertNumTxes(1, numExpected)
-
- // For leased channels, we need to mine one more block to
- // confirm Bob's commit output sweep.
- //
- // NOTE: we mine this block conditionally, as the commit output
- // may have already been swept one block earlier due to the
- // race in block consumption among subsystems.
- pendingChanResp := bob.RPC.PendingChannels()
- if len(pendingChanResp.PendingForceClosingChannels) != 0 {
- // Check that the sweep spends the expected inputs.
- ht.AssertOutpointInMempool(commitOutpoint)
- ht.MineBlocksAndAssertNumTxes(1, 1)
- }
- } else {
- // Since Bob force closed the channel between him and Carol, he
- // will incur the usual CSV delay on any outputs that he can
- // sweep back to his wallet. We'll subtract one block from our
- // current maturity period to assert on the mempool.
- numBlocks := int(forceCloseChan.BlocksTilMaturity - 1)
- ht.MineEmptyBlocks(numBlocks)
-
- // Check that Bob has a pending sweeping tx.
- ht.AssertNumPendingSweeps(bob, 1)
-
- // Mine a block the trigger the sweeping behavior.
- ht.MineEmptyBlocks(1)
-
- // Check that the sweep spends from the mined commitment.
- ht.AssertOutpointInMempool(commitOutpoint)
-
- // Mine one more block to trigger the timeout path.
- ht.MineBlocksAndAssertNumTxes(1, 1)
-
- // Bob's sweeper should now broadcast his second layer sweep
- // due to the CSV on the HTLC timeout output.
- ht.AssertOutpointInMempool(htlcTimeoutOutpoint)
-
- // Next, we'll mine a final block that should confirm the
- // sweeping transactions left.
- ht.MineBlocksAndAssertNumTxes(1, 1)
- }
-
- // Once this transaction has been confirmed, Bob should detect that he
- // no longer has any pending channels.
- ht.AssertNumPendingForceClose(bob, 0)
-
- // Coop close channel, expect no anchors.
- ht.CloseChannel(alice, aliceChanPoint)
-}
-
-// testMultiHopReceiverChainClaim tests that in the multi-hop setting, if the
-// receiver of an HTLC knows the preimage, but wasn't able to settle the HTLC
-// off-chain, then it goes on chain to claim the HTLC uing the HTLC success
-// transaction. In this scenario, the node that sent the outgoing HTLC should
-// extract the preimage from the sweep transaction, and finish settling the
-// HTLC backwards into the route.
-func testMultiHopReceiverChainClaim(ht *lntest.HarnessTest) {
- runMultiHopHtlcClaimTest(ht, runMultiHopReceiverChainClaim)
-}
-
-func runMultiHopReceiverChainClaim(ht *lntest.HarnessTest,
- alice, bob *node.HarnessNode, c lnrpc.CommitmentType, zeroConf bool) {
-
- // First, we'll create a three hop network: Alice -> Bob -> Carol, with
- // Carol refusing to actually settle or directly cancel any HTLC's
- // self.
- aliceChanPoint, bobChanPoint, carol := createThreeHopNetwork(
- ht, alice, bob, false, c, zeroConf,
- )
-
- // For neutrino backend, we need to fund one more UTXO for Carol so she
- // can sweep her outputs.
- if ht.IsNeutrinoBackend() {
- ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
- }
-
- // If this is a taproot channel, then we'll need to make some manual
- // route hints so Alice can actually find a route.
- var routeHints []*lnrpc.RouteHint
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- routeHints = makeRouteHints(bob, carol, zeroConf)
- }
-
- // With the network active, we'll now add a new hodl invoice at Carol's
- // end. Make sure the cltv expiry delta is large enough, otherwise Bob
- // won't send out the outgoing htlc.
- const invoiceAmt = 100000
- var preimage lntypes.Preimage
- copy(preimage[:], ht.Random32Bytes())
- payHash := preimage.Hash()
- invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
- Value: invoiceAmt,
- CltvExpiry: finalCltvDelta,
- Hash: payHash[:],
- RouteHints: routeHints,
- }
- carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
-
- // Subscribe the invoice.
- stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
-
- // Now that we've created the invoice, we'll send a single payment from
- // Alice to Carol. We won't wait for the response however, as Carol
- // will not immediately settle the payment.
- req := &routerrpc.SendPaymentRequest{
- PaymentRequest: carolInvoice.PaymentRequest,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- }
- alice.RPC.SendPayment(req)
-
- // At this point, all 3 nodes should now have an active channel with
- // the created HTLC pending on all of them.
- ht.AssertActiveHtlcs(alice, payHash[:])
- ht.AssertActiveHtlcs(bob, payHash[:])
- ht.AssertActiveHtlcs(carol, payHash[:])
-
- // Wait for carol to mark invoice as accepted. There is a small gap to
- // bridge between adding the htlc to the channel and executing the exit
- // hop logic.
- ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
-
- restartBob := ht.SuspendNode(bob)
-
- // Settle invoice. This will just mark the invoice as settled, as there
- // is no link anymore to remove the htlc from the commitment tx. For
- // this test, it is important to actually settle and not leave the
- // invoice in the accepted state, because without a known preimage, the
- // channel arbitrator won't go to chain.
- carol.RPC.SettleInvoice(preimage[:])
-
- // Increase the fee estimate so that the following force close tx will
- // be cpfp'ed.
- ht.SetFeeEstimate(30000)
-
- // We now advance the block height to the point where Carol will force
- // close her channel with Bob, broadcast the closing tx but keep it
- // unconfirmed.
- numBlocks := padCLTV(uint32(
- invoiceReq.CltvExpiry - lncfg.DefaultIncomingBroadcastDelta,
- ))
-
- // Now we'll mine enough blocks to prompt carol to actually go to the
- // chain in order to sweep her HTLC since the value is high enough.
- ht.MineEmptyBlocks(int(numBlocks))
-
- // At this point, Carol should broadcast her active commitment
- // transaction in order to go to the chain and sweep her HTLC.
- ht.AssertNumTxsInMempool(1)
-
- closingTx := ht.AssertOutpointInMempool(
- ht.OutPointFromChannelPoint(bobChanPoint),
- )
- closingTxid := closingTx.TxHash()
-
- // Carol's anchor should have been offered to her sweeper as she has
- // time-sensitive HTLCs. Assert that we have two anchors - one for the
- // anchor on the local commitment and the other for the anchor on the
- // remote commitment (invalid).
- ht.AssertNumPendingSweeps(carol, 2)
-
- // Confirm the commitment.
- ht.MineBlocksAndAssertNumTxes(1, 1)
-
- // The above mined block will trigger Carol's sweeper to publish the
- // anchor sweeping tx.
- //
- // TODO(yy): should instead cancel the broadcast of the anchor sweeping
- // tx to save fees since we know the force close tx has been confirmed?
- // This is very difficult as it introduces more complicated RBF
- // scenarios, as we are using a wallet utxo, which means any txns using
- // that wallet utxo must pay more fees. On the other hand, there's no
- // way to remove that anchor-CPFP tx from the mempool.
- ht.AssertNumTxsInMempool(1)
-
- // After the force close transaction is mined, Carol should offer her
- // second level HTLC tx to the sweeper, which means we should see two
- // pending inputs now - the anchor and the htlc.
- ht.AssertNumPendingSweeps(carol, 2)
-
- // Restart bob again.
- require.NoError(ht, restartBob())
-
- var expectedTxes int
-
- // After the force close transaction is mined, a series of transactions
- // should be broadcast by Bob and Carol. When Bob notices Carol's
- // second level transaction in the mempool, he will extract the
- // preimage and settle the HTLC back off-chain.
- switch c {
- // We expect to see three txns in the mempool:
- // 1. Carol should broadcast her second level HTLC tx.
- // 2. Carol should broadcast her anchor sweeping tx.
- // 3. Bob should broadcast a sweep tx to sweep his output in the
- // channel with Carol, and in the same sweep tx to sweep his anchor
- // output.
- case lnrpc.CommitmentType_ANCHORS, lnrpc.CommitmentType_SIMPLE_TAPROOT:
- expectedTxes = 3
- ht.AssertNumPendingSweeps(bob, 2)
-
- // We expect to see two txns in the mempool:
- // 1. Carol should broadcast her second level HTLC tx.
- // 2. Carol should broadcast her anchor sweeping tx.
- // Bob would offer his anchor output to his sweeper, but it cannot be
- // swept due to it being uneconomical. Bob's commit output can't be
- // swept yet as he's incurring an additional CLTV from being the
- // channel initiator of a script-enforced leased channel.
- case lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE:
- expectedTxes = 2
- ht.AssertNumPendingSweeps(bob, 1)
-
- default:
- ht.Fatalf("unhandled commitment type %v", c)
- }
-
- // Mine one block to trigger the sweeper to sweep.
- ht.MineEmptyBlocks(1)
-
- // All transactions should be spending from the commitment transaction.
- txes := ht.GetNumTxsFromMempool(expectedTxes)
- ht.AssertAllTxesSpendFrom(txes, closingTxid)
-
- // We'll now mine an additional block which should confirm both the
- // second layer transactions.
- ht.MineBlocksAndAssertNumTxes(1, expectedTxes)
-
- // Carol's pending channel report should now show two outputs under
- // limbo: her commitment output, as well as the second-layer claim
- // output, and the pending HTLC should also now be in stage 2.
- ht.AssertNumHTLCsAndStage(carol, bobChanPoint, 1, 2)
-
- // Once the second-level transaction confirmed, Bob should have
- // extracted the preimage from the chain, and sent it back to Alice,
- // clearing the HTLC off-chain.
- ht.AssertNumActiveHtlcs(alice, 0)
-
- // If we mine 4 additional blocks, then Carol can sweep the second
- // level HTLC output once the CSV expires.
- ht.MineEmptyBlocks(defaultCSV - 1)
-
- // Assert Carol has the pending HTLC sweep.
- ht.AssertNumPendingSweeps(carol, 1)
-
- // Mine one block to trigger the sweeper to sweep.
- ht.MineEmptyBlocks(1)
-
- // We should have a new transaction in the mempool.
- ht.AssertNumTxsInMempool(1)
-
- // Finally, if we mine an additional block to confirm Carol's second
- // level success transaction. Carol should not show a pending channel
- // in her report afterwards.
- ht.MineBlocksAndAssertNumTxes(1, 1)
- ht.AssertNumPendingForceClose(carol, 0)
-
- // The invoice should show as settled for Carol, indicating that it was
- // swept on-chain.
- ht.AssertInvoiceSettled(carol, carolInvoice.PaymentAddr)
-
- // Finally, check that the Alice's payment is correctly marked
- // succeeded.
- ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
-
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- // Bob still has his commit output to sweep to since he
- // incurred an additional CLTV from being the channel initiator
- // of a script-enforced leased channel, regardless of whether
- // he forced closed the channel or not.
- pendingChanResp := bob.RPC.PendingChannels()
-
- require.Len(ht, pendingChanResp.PendingForceClosingChannels, 1)
- forceCloseChan := pendingChanResp.PendingForceClosingChannels[0]
- require.Positive(ht, forceCloseChan.LimboBalance)
- require.Positive(ht, forceCloseChan.BlocksTilMaturity)
-
- // TODO: Bob still shows a pending HTLC at this point when he
- // shouldn't, as he already extracted the preimage from Carol's
- // claim.
- // require.Len(t.t, forceCloseChan.PendingHtlcs, 0)
-
- // Mine enough blocks for Bob's commit output's CLTV to expire
- // and sweep it.
- numBlocks := int(forceCloseChan.BlocksTilMaturity)
- ht.MineEmptyBlocks(numBlocks)
-
- // Bob should have two pending inputs to be swept, the commit
- // output and the anchor output.
- ht.AssertNumPendingSweeps(bob, 2)
- ht.MineEmptyBlocks(1)
-
- commitOutpoint := wire.OutPoint{Hash: closingTxid, Index: 3}
- ht.AssertOutpointInMempool(commitOutpoint)
- ht.MineBlocksAndAssertNumTxes(1, 1)
- }
-
- ht.AssertNumPendingForceClose(bob, 0)
-
- // We'll close out the channel between Alice and Bob, then shutdown
- // carol to conclude the test.
- ht.CloseChannel(alice, aliceChanPoint)
-}
-
-// testMultiHopLocalForceCloseOnChainHtlcTimeout tests that in a multi-hop HTLC
-// scenario, if the node that extended the HTLC to the final node closes their
-// commitment on-chain early, then it eventually recognizes this HTLC as one
-// that's timed out. At this point, the node should timeout the HTLC using the
-// HTLC timeout transaction, then cancel it backwards as normal.
-func testMultiHopLocalForceCloseOnChainHtlcTimeout(ht *lntest.HarnessTest) {
- runMultiHopHtlcClaimTest(
- ht, runMultiHopLocalForceCloseOnChainHtlcTimeout,
- )
-}
-
-func runMultiHopLocalForceCloseOnChainHtlcTimeout(ht *lntest.HarnessTest,
- alice, bob *node.HarnessNode, c lnrpc.CommitmentType, zeroConf bool) {
-
- // First, we'll create a three hop network: Alice -> Bob -> Carol, with
- // Carol refusing to actually settle or directly cancel any HTLC's
- // self.
- aliceChanPoint, bobChanPoint, carol := createThreeHopNetwork(
- ht, alice, bob, true, c, zeroConf,
- )
-
- // With our channels set up, we'll then send a single HTLC from Alice
- // to Carol. As Carol is in hodl mode, she won't settle this HTLC which
- // opens up the base for out tests.
- const htlcAmt = btcutil.Amount(300_000)
-
- // If this is a taproot channel, then we'll need to make some manual
- // route hints so Alice can actually find a route.
- var routeHints []*lnrpc.RouteHint
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- routeHints = makeRouteHints(bob, carol, zeroConf)
- }
-
- // We'll now send a single HTLC across our multi-hop network.
- carolPubKey := carol.PubKey[:]
- payHash := ht.Random32Bytes()
- req := &routerrpc.SendPaymentRequest{
- Dest: carolPubKey,
- Amt: int64(htlcAmt),
- PaymentHash: payHash,
- FinalCltvDelta: finalCltvDelta,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- RouteHints: routeHints,
- }
- alice.RPC.SendPayment(req)
-
- // Once the HTLC has cleared, all channels in our mini network should
- // have the it locked in.
- ht.AssertActiveHtlcs(alice, payHash)
- ht.AssertActiveHtlcs(bob, payHash)
- ht.AssertActiveHtlcs(carol, payHash)
-
- // blocksMined records how many blocks have mined after the creation of
- // the invoice so it can be used to calculate how many more blocks need
- // to be mined to trigger a force close later on.
- var blocksMined uint32
-
- // Now that all parties have the HTLC locked in, we'll immediately
- // force close the Bob -> Carol channel. This should trigger contract
- // resolution mode for both of them.
- stream, _ := ht.CloseChannelAssertPending(bob, bobChanPoint, true)
- closeTx := ht.AssertStreamChannelForceClosed(
- bob, bobChanPoint, true, stream,
- )
-
- // Increase the blocks mined. At the step
- // AssertStreamChannelForceClosed mines one block.
- blocksMined++
-
- // The channel close has anchors, we should expect to see both Bob and
- // Carol has a pending sweep request for the anchor sweep.
- ht.AssertNumPendingSweeps(carol, 1)
- ht.AssertNumPendingSweeps(bob, 1)
-
- // Mine a block to confirm Bob's anchor sweep - Carol's anchor sweep
- // won't succeed because it's not used for CPFP, so there's no wallet
- // utxo used, resulting it to be uneconomical.
- ht.MineBlocksAndAssertNumTxes(1, 1)
- blocksMined++
-
- htlcOutpoint := wire.OutPoint{Hash: closeTx, Index: 2}
- bobCommitOutpoint := wire.OutPoint{Hash: closeTx, Index: 3}
-
- // Before the HTLC times out, we'll need to assert that Bob broadcasts
- // a sweep transaction for his commit output. Note that if the channel
- // has a script-enforced lease, then Bob will have to wait for an
- // additional CLTV before sweeping it.
- if c != lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- // The sweep is broadcast on the block immediately before the
- // CSV expires and the commitment was already mined inside
- // AssertStreamChannelForceClosed(), so mine one block less
- // than defaultCSV in order to perform mempool assertions.
- ht.MineEmptyBlocks(int(defaultCSV - blocksMined))
- blocksMined = defaultCSV
-
- // Assert Bob has the sweep and trigger it.
- ht.AssertNumPendingSweeps(bob, 1)
- ht.MineEmptyBlocks(1)
- blocksMined++
-
- commitSweepTx := ht.AssertOutpointInMempool(
- bobCommitOutpoint,
- )
- txid := commitSweepTx.TxHash()
- block := ht.MineBlocksAndAssertNumTxes(1, 1)[0]
- ht.AssertTxInBlock(block, txid)
-
- blocksMined++
- }
-
- // We'll now mine enough blocks for the HTLC to expire. After this, Bob
- // should hand off the now expired HTLC output to the utxo nursery.
- numBlocks := padCLTV(uint32(finalCltvDelta) -
- lncfg.DefaultOutgoingBroadcastDelta)
- ht.MineEmptyBlocks(int(numBlocks - blocksMined))
-
- // Bob's pending channel report should show that he has a single HTLC
- // that's now in stage one.
- ht.AssertNumHTLCsAndStage(bob, bobChanPoint, 1, 1)
-
- // Bob should have a pending sweep request.
- ht.AssertNumPendingSweeps(bob, 1)
-
- // Mine one block to trigger Bob's sweeper to sweep it.
- ht.MineEmptyBlocks(1)
-
- // We should also now find a transaction in the mempool, as Bob should
- // have broadcast his second layer timeout transaction.
- timeoutTx := ht.AssertOutpointInMempool(htlcOutpoint).TxHash()
-
- // Next, we'll mine an additional block. This should serve to confirm
- // the second layer timeout transaction.
- block := ht.MineBlocksAndAssertNumTxes(1, 1)[0]
- ht.AssertTxInBlock(block, timeoutTx)
-
- // With the second layer timeout transaction confirmed, Bob should have
- // canceled backwards the HTLC that carol sent.
- ht.AssertNumActiveHtlcs(bob, 0)
-
- // Additionally, Bob should now show that HTLC as being advanced to the
- // second stage.
- ht.AssertNumHTLCsAndStage(bob, bobChanPoint, 1, 2)
-
- // Bob should now broadcast a transaction that sweeps certain inputs
- // depending on the commitment type. We'll need to mine some blocks
- // before the broadcast is possible.
- resp := bob.RPC.PendingChannels()
-
- require.Len(ht, resp.PendingForceClosingChannels, 1)
- forceCloseChan := resp.PendingForceClosingChannels[0]
- require.Len(ht, forceCloseChan.PendingHtlcs, 1)
- pendingHtlc := forceCloseChan.PendingHtlcs[0]
- require.Positive(ht, pendingHtlc.BlocksTilMaturity)
- numBlocks = uint32(pendingHtlc.BlocksTilMaturity)
-
- ht.MineEmptyBlocks(int(numBlocks))
-
- var numExpected int
-
- // Now that the CSV/CLTV timelock has expired, the transaction should
- // either only sweep the HTLC timeout transaction, or sweep both the
- // HTLC timeout transaction and Bob's commit output depending on the
- // commitment type.
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- // Assert the expected number of pending sweeps are found.
- sweeps := ht.AssertNumPendingSweeps(bob, 2)
-
- numExpected = 1
- if sweeps[0].DeadlineHeight != sweeps[1].DeadlineHeight {
- numExpected = 2
- }
- } else {
- ht.AssertNumPendingSweeps(bob, 1)
- numExpected = 1
- }
-
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
-
- // Assert the sweeping tx is found in the mempool.
- htlcTimeoutOutpoint := wire.OutPoint{Hash: timeoutTx, Index: 0}
- ht.AssertOutpointInMempool(htlcTimeoutOutpoint)
-
- // Mine a block to confirm the sweep.
- ht.MineBlocksAndAssertNumTxes(1, numExpected)
-
- // At this point, Bob should no longer show any channels as pending
- // close.
- ht.AssertNumPendingForceClose(bob, 0)
-
- // Coop close, no anchors.
- ht.CloseChannel(alice, aliceChanPoint)
-}
-
-// testMultiHopRemoteForceCloseOnChainHtlcTimeout tests that if we extend a
-// multi-hop HTLC, and the final destination of the HTLC force closes the
-// channel, then we properly timeout the HTLC directly on *their* commitment
-// transaction once the timeout has expired. Once we sweep the transaction, we
-// should also cancel back the initial HTLC.
-func testMultiHopRemoteForceCloseOnChainHtlcTimeout(ht *lntest.HarnessTest) {
- runMultiHopHtlcClaimTest(
- ht, runMultiHopRemoteForceCloseOnChainHtlcTimeout,
- )
-}
-
-func runMultiHopRemoteForceCloseOnChainHtlcTimeout(ht *lntest.HarnessTest,
- alice, bob *node.HarnessNode, c lnrpc.CommitmentType, zeroConf bool) {
-
- // First, we'll create a three hop network: Alice -> Bob -> Carol, with
- // Carol refusing to actually settle or directly cancel any HTLC's
- // self.
- aliceChanPoint, bobChanPoint, carol := createThreeHopNetwork(
- ht, alice, bob, true, c, zeroConf,
- )
-
- // With our channels set up, we'll then send a single HTLC from Alice
- // to Carol. As Carol is in hodl mode, she won't settle this HTLC which
- // opens up the base for out tests.
- const htlcAmt = btcutil.Amount(30000)
-
- // If this is a taproot channel, then we'll need to make some manual
- // route hints so Alice can actually find a route.
- var routeHints []*lnrpc.RouteHint
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- routeHints = makeRouteHints(bob, carol, zeroConf)
- }
-
- // We'll now send a single HTLC across our multi-hop network.
- var preimage lntypes.Preimage
- copy(preimage[:], ht.Random32Bytes())
- payHash := preimage.Hash()
- invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
- Value: int64(htlcAmt),
- CltvExpiry: finalCltvDelta,
- Hash: payHash[:],
- RouteHints: routeHints,
- }
- carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
-
- // Subscribe the invoice.
- stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
-
- req := &routerrpc.SendPaymentRequest{
- PaymentRequest: carolInvoice.PaymentRequest,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- }
- alice.RPC.SendPayment(req)
-
- // blocksMined records how many blocks have mined after the creation of
- // the invoice so it can be used to calculate how many more blocks need
- // to be mined to trigger a force close later on.
- var blocksMined uint32
-
- // Once the HTLC has cleared, all the nodes in our mini network should
- // show that the HTLC has been locked in.
- ht.AssertActiveHtlcs(alice, payHash[:])
- ht.AssertActiveHtlcs(bob, payHash[:])
- ht.AssertActiveHtlcs(carol, payHash[:])
-
- // At this point, we'll now instruct Carol to force close the
- // transaction. This will let us exercise that Bob is able to sweep the
- // expired HTLC on Carol's version of the commitment transaction.
- closeStream, _ := ht.CloseChannelAssertPending(
- carol, bobChanPoint, true,
- )
-
- // For anchor channels, the anchor won't be used for CPFP because
- // channel arbitrator thinks Carol doesn't have preimage for her
- // incoming HTLC on the commitment transaction Bob->Carol. Although
- // Carol created this invoice, because it's a hold invoice, the
- // preimage won't be generated automatically.
- closeTx := ht.AssertStreamChannelForceClosed(
- carol, bobChanPoint, true, closeStream,
- )
-
- // Increase the blocks mined. At this step
- // AssertStreamChannelForceClosed mines one block.
- blocksMined++
-
- // At this point, Bob should have a pending force close channel as
- // Carol has gone directly to chain.
- ht.AssertNumPendingForceClose(bob, 1)
-
- var expectedTxes int
- switch c {
- // Bob can sweep his commit and anchor outputs immediately. Carol will
- // also offer her anchor to her sweeper.
- case lnrpc.CommitmentType_ANCHORS, lnrpc.CommitmentType_SIMPLE_TAPROOT:
- ht.AssertNumPendingSweeps(bob, 2)
- ht.AssertNumPendingSweeps(carol, 1)
-
- // We expect to see only one sweeping tx to be published from
- // Bob, which sweeps his commit and anchor outputs in the same
- // tx. For Carol, since her anchor is not used for CPFP, it'd
- // be uneconomical to sweep so it will fail.
- expectedTxes = 1
-
- // Bob can't sweep his commit output yet as he was the initiator of a
- // script-enforced leased channel, so he'll always incur the additional
- // CLTV. He can still offer his anchor output to his sweeper however.
- case lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE:
- ht.AssertNumPendingSweeps(bob, 1)
- ht.AssertNumPendingSweeps(carol, 1)
-
- // We expect to see only no sweeping txns to be published,
- // neither Bob's or Carol's anchor sweep can succeed due to
- // it's uneconomical.
- expectedTxes = 0
-
- default:
- ht.Fatalf("unhandled commitment type %v", c)
- }
-
- // Mine one block to trigger the sweeps.
- ht.MineEmptyBlocks(1)
- blocksMined++
-
- // We now mine a block to clear up the mempool.
- ht.MineBlocksAndAssertNumTxes(1, expectedTxes)
- blocksMined++
-
- // Next, we'll mine enough blocks for the HTLC to expire. At this
- // point, Bob should hand off the output to his internal utxo nursery,
- // which will broadcast a sweep transaction.
- numBlocks := padCLTV(uint32(finalCltvDelta) -
- lncfg.DefaultOutgoingBroadcastDelta)
- ht.MineEmptyBlocks(int(numBlocks - blocksMined))
-
- // If we check Bob's pending channel report, it should show that he has
- // a single HTLC that's now in the second stage, as it skipped the
- // initial first stage since this is a direct HTLC.
- ht.AssertNumHTLCsAndStage(bob, bobChanPoint, 1, 2)
-
- // We need to generate an additional block to expire the CSV 1.
- ht.MineEmptyBlocks(1)
-
- // For script-enforced leased channels, Bob has failed to sweep his
- // anchor output before, so it's still pending.
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- ht.AssertNumPendingSweeps(bob, 2)
- } else {
- // Bob should have a pending sweep request.
- ht.AssertNumPendingSweeps(bob, 1)
- }
-
- // Mine a block to trigger the sweeper to sweep it.
- ht.MineEmptyBlocks(1)
-
- // Bob's sweeping transaction should now be found in the mempool at
- // this point.
- sweepTx := ht.AssertNumTxsInMempool(1)[0]
-
- // If we mine an additional block, then this should confirm Bob's
- // transaction which sweeps the direct HTLC output.
- block := ht.MineBlocksAndAssertNumTxes(1, 1)[0]
- ht.AssertTxInBlock(block, sweepTx)
-
- // Now that the sweeping transaction has been confirmed, Bob should
- // cancel back that HTLC. As a result, Alice should not know of any
- // active HTLC's.
- ht.AssertNumActiveHtlcs(alice, 0)
-
- // Now we'll check Bob's pending channel report. Since this was Carol's
- // commitment, he doesn't have to wait for any CSV delays, but he may
- // still need to wait for a CLTV on his commit output to expire
- // depending on the commitment type.
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- resp := bob.RPC.PendingChannels()
-
- require.Len(ht, resp.PendingForceClosingChannels, 1)
- forceCloseChan := resp.PendingForceClosingChannels[0]
- require.Positive(ht, forceCloseChan.BlocksTilMaturity)
-
- numBlocks := int(forceCloseChan.BlocksTilMaturity)
- ht.MineEmptyBlocks(numBlocks)
-
- // Assert the commit output has been offered to the sweeper.
- // Bob should have two pending sweep requests - one for the
- // commit output and one for the anchor output.
- ht.AssertNumPendingSweeps(bob, 2)
-
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
-
- bobCommitOutpoint := wire.OutPoint{Hash: closeTx, Index: 3}
- bobCommitSweep := ht.AssertOutpointInMempool(
- bobCommitOutpoint,
- )
- bobCommitSweepTxid := bobCommitSweep.TxHash()
- block := ht.MineBlocksAndAssertNumTxes(1, 1)[0]
- ht.AssertTxInBlock(block, bobCommitSweepTxid)
- }
- ht.AssertNumPendingForceClose(bob, 0)
-
- // While we're here, we assert that our expired invoice's state is
- // correctly updated, and can no longer be settled.
- ht.AssertInvoiceState(stream, lnrpc.Invoice_CANCELED)
-
- // We'll close out the test by closing the channel from Alice to Bob,
- // and then shutting down the new node we created as its no longer
- // needed. Coop close, no anchors.
- ht.CloseChannel(alice, aliceChanPoint)
-}
-
-// testMultiHopHtlcLocalChainClaim tests that in a multi-hop HTLC scenario, if
-// we force close a channel with an incoming HTLC, and later find out the
-// preimage via the witness beacon, we properly settle the HTLC on-chain using
-// the HTLC success transaction in order to ensure we don't lose any funds.
-func testMultiHopHtlcLocalChainClaim(ht *lntest.HarnessTest) {
- runMultiHopHtlcClaimTest(ht, runMultiHopHtlcLocalChainClaim)
-}
-
-func runMultiHopHtlcLocalChainClaim(ht *lntest.HarnessTest,
- alice, bob *node.HarnessNode, c lnrpc.CommitmentType, zeroConf bool) {
-
- // First, we'll create a three hop network: Alice -> Bob -> Carol, with
- // Carol refusing to actually settle or directly cancel any HTLC's
- // self.
- aliceChanPoint, bobChanPoint, carol := createThreeHopNetwork(
- ht, alice, bob, false, c, zeroConf,
- )
-
- // For neutrino backend, we need to fund one more UTXO for Carol so she
- // can sweep her outputs.
- if ht.IsNeutrinoBackend() {
- ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
- }
-
- // If this is a taproot channel, then we'll need to make some manual
- // route hints so Alice can actually find a route.
- var routeHints []*lnrpc.RouteHint
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- routeHints = makeRouteHints(bob, carol, zeroConf)
- }
-
- // With the network active, we'll now add a new hodl invoice at Carol's
- // end. Make sure the cltv expiry delta is large enough, otherwise Bob
- // won't send out the outgoing htlc.
- const invoiceAmt = 100000
- var preimage lntypes.Preimage
- copy(preimage[:], ht.Random32Bytes())
- payHash := preimage.Hash()
- invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
- Value: invoiceAmt,
- CltvExpiry: finalCltvDelta,
- Hash: payHash[:],
- RouteHints: routeHints,
- }
- carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
-
- // Subscribe the invoice.
- stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
-
- // Now that we've created the invoice, we'll send a single payment from
- // Alice to Carol. We won't wait for the response however, as Carol
- // will not immediately settle the payment.
- req := &routerrpc.SendPaymentRequest{
- PaymentRequest: carolInvoice.PaymentRequest,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- }
- alice.RPC.SendPayment(req)
-
- // At this point, all 3 nodes should now have an active channel with
- // the created HTLC pending on all of them.
- ht.AssertActiveHtlcs(alice, payHash[:])
- ht.AssertActiveHtlcs(bob, payHash[:])
- ht.AssertActiveHtlcs(carol, payHash[:])
-
- // Wait for carol to mark invoice as accepted. There is a small gap to
- // bridge between adding the htlc to the channel and executing the exit
- // hop logic.
- ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
-
- // blocksMined records how many blocks have mined after the creation of
- // the invoice so it can be used to calculate how many more blocks need
- // to be mined to trigger a force close later on.
- var blocksMined uint32
-
- // At this point, Bob decides that he wants to exit the channel
- // immediately, so he force closes his commitment transaction.
- closeStream, _ := ht.CloseChannelAssertPending(
- bob, aliceChanPoint, true,
- )
-
- // For anchor channels, the anchor won't be used for CPFP as there's no
- // deadline pressure for Bob on the channel Alice->Bob at the moment.
- // For Bob's local commitment tx, there's only one incoming HTLC which
- // he doesn't have the preimage yet. Thus this anchor won't be
- // force-swept.
- hasAnchorSweep := false
- bobForceClose := ht.AssertStreamChannelForceClosed(
- bob, aliceChanPoint, hasAnchorSweep, closeStream,
- )
-
- // Increase the blocks mined. At this step
- // AssertStreamChannelForceClosed mines one block.
- blocksMined++
-
- var expectedTxes int
- switch c {
- // Alice will sweep her commitment and anchor output immediately. Bob
- // will also offer his anchor to his sweeper.
- case lnrpc.CommitmentType_ANCHORS, lnrpc.CommitmentType_SIMPLE_TAPROOT:
- ht.AssertNumPendingSweeps(alice, 2)
- ht.AssertNumPendingSweeps(bob, 1)
-
- // We expect to see only one sweeping tx to be published from
- // Alice, which sweeps her commit and anchor outputs in the
- // same tx. For Bob, since his anchor is not used for CPFP,
- // it'd be uneconomical to sweep so it will fail.
- expectedTxes = 1
-
- // Alice will offer her anchor output to her sweeper. Her commitment
- // output cannot be swept yet as it has incurred an additional CLTV due
- // to being the initiator of a script-enforced leased channel.
- case lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE:
- ht.AssertNumPendingSweeps(alice, 1)
- ht.AssertNumPendingSweeps(bob, 1)
-
- // We expect to see only no sweeping txns to be published,
- // neither Alice's or Bob's anchor sweep can succeed due to
- // it's uneconomical.
- expectedTxes = 0
-
- default:
- ht.Fatalf("unhandled commitment type %v", c)
- }
-
- // Mine a block to trigger the sweeps.
- ht.MineEmptyBlocks(1)
- blocksMined++
-
- // Assert the expected num of txns are found in the mempool.
- ht.AssertNumTxsInMempool(expectedTxes)
-
- // Mine a block to clean up the mempool for the rest of the test.
- ht.MineBlocksAndAssertNumTxes(1, expectedTxes)
- blocksMined++
-
- // Suspend Bob to force Carol to go to chain.
- restartBob := ht.SuspendNode(bob)
-
- // Settle invoice. This will just mark the invoice as settled, as there
- // is no link anymore to remove the htlc from the commitment tx. For
- // this test, it is important to actually settle and not leave the
- // invoice in the accepted state, because without a known preimage, the
- // channel arbitrator won't go to chain.
- carol.RPC.SettleInvoice(preimage[:])
-
- // We now advance the block height to the point where Carol will force
- // close her channel with Bob, broadcast the closing tx but keep it
- // unconfirmed.
- numBlocks := padCLTV(uint32(invoiceReq.CltvExpiry -
- lncfg.DefaultIncomingBroadcastDelta))
- ht.MineEmptyBlocks(int(numBlocks - blocksMined))
-
- // Carol's commitment transaction should now be in the mempool.
- ht.AssertNumTxsInMempool(1)
-
- // Look up the closing transaction. It should be spending from the
- // funding transaction,
- closingTx := ht.AssertOutpointInMempool(
- ht.OutPointFromChannelPoint(bobChanPoint),
- )
- closingTxid := closingTx.TxHash()
-
- // Mine a block that should confirm the commit tx.
- block := ht.MineBlocksAndAssertNumTxes(1, 1)[0]
- ht.AssertTxInBlock(block, closingTxid)
-
- // After the force close transaction is mined, Carol should offer her
- // second-level success HTLC tx and anchor to the sweeper.
- ht.AssertNumPendingSweeps(carol, 2)
-
- // Restart bob again.
- require.NoError(ht, restartBob())
-
- // Lower the fee rate so Bob's two anchor outputs are economical to
- // be swept in one tx.
- ht.SetFeeEstimate(chainfee.FeePerKwFloor)
-
- // After the force close transaction is mined, transactions will be
- // broadcast by both Bob and Carol.
- switch c {
- // Carol will broadcast her sweeping txns and Bob will sweep his
- // commitment and anchor outputs, we'd expect to see three txns,
- // - Carol's second level HTLC transaction.
- // - Carol's anchor sweeping txns since it's used for CPFP.
- // - Bob's sweep tx spending his commitment output, and two anchor
- // outputs, one from channel Alice to Bob and the other from channel
- // Bob to Carol.
- case lnrpc.CommitmentType_ANCHORS, lnrpc.CommitmentType_SIMPLE_TAPROOT:
- ht.AssertNumPendingSweeps(bob, 3)
- expectedTxes = 3
-
- // Carol will broadcast her sweeping txns and Bob will sweep his
- // anchor outputs. Bob can't sweep his commitment output yet as it has
- // incurred an additional CLTV due to being the initiator of a
- // script-enforced leased channel:
- // - Carol's second level HTLC transaction.
- // - Carol's anchor sweeping txns since it's used for CPFP.
- // - Bob's sweep tx spending his two anchor outputs, one from channel
- // Alice to Bob and the other from channel Bob to Carol.
- case lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE:
- ht.AssertNumPendingSweeps(bob, 2)
- expectedTxes = 3
-
- default:
- ht.Fatalf("unhandled commitment type %v", c)
- }
-
- // Mine a block to trigger the sweeps.
- ht.MineEmptyBlocks(1)
-
- // Assert transactions can be found in the mempool.
- ht.AssertNumTxsInMempool(expectedTxes)
-
- // At this point we suspend Alice to make sure she'll handle the
- // on-chain settle after a restart.
- restartAlice := ht.SuspendNode(alice)
-
- // Mine a block to confirm the expected transactions (+ the coinbase).
- ht.MineBlocksAndAssertNumTxes(1, expectedTxes)
-
- // For a channel of the anchor type, we will subtract one block
- // from the default CSV, as the Sweeper will handle the input, and the
- // Sweeper sweeps the input as soon as the lock expires.
- secondLevelMaturity := uint32(defaultCSV - 1)
-
- // Keep track of the second level tx maturity.
- carolSecondLevelCSV := secondLevelMaturity
-
- // When Bob notices Carol's second level transaction in the block, he
- // will extract the preimage and broadcast a second level tx to claim
- // the HTLC in his (already closed) channel with Alice.
- ht.AssertNumPendingSweeps(bob, 1)
-
- // Mine a block to trigger the sweep of the second level tx.
- ht.MineEmptyBlocks(1)
- carolSecondLevelCSV--
-
- // Check Bob's second level tx.
- bobSecondLvlTx := ht.GetNumTxsFromMempool(1)[0]
-
- // It should spend from the commitment in the channel with Alice.
- ht.AssertTxSpendFrom(bobSecondLvlTx, bobForceClose)
-
- // At this point, Bob should have broadcast his second layer success
- // transaction, and should have sent it to the nursery for incubation.
- ht.AssertNumHTLCsAndStage(bob, aliceChanPoint, 1, 1)
-
- // The channel between Bob and Carol will still be pending force close
- // if this is a leased channel. In that case, we'd also check the HTLC
- // stages are correct in that channel.
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- ht.AssertNumPendingForceClose(bob, 2)
- ht.AssertNumHTLCsAndStage(bob, bobChanPoint, 1, 1)
- } else {
- ht.AssertNumPendingForceClose(bob, 1)
- }
-
- // We'll now mine a block which should confirm Bob's second layer
- // transaction.
- ht.MineBlocksAndAssertNumTxes(1, 1)
-
- // Keep track of Bob's second level maturity, and decrement our track
- // of Carol's.
- bobSecondLevelCSV := secondLevelMaturity
- carolSecondLevelCSV--
-
- // Now that the preimage from Bob has hit the chain, restart Alice to
- // ensure she'll pick it up.
- require.NoError(ht, restartAlice())
-
- // If we then mine 1 additional blocks, Carol's second level tx should
- // mature, and she can pull the funds from it with a sweep tx.
- ht.MineEmptyBlocks(int(carolSecondLevelCSV))
- bobSecondLevelCSV -= carolSecondLevelCSV
-
- // Carol should have one a sweep request for her second level tx.
- ht.AssertNumPendingSweeps(carol, 1)
-
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
- bobSecondLevelCSV--
-
- // Carol's sweep tx should be broadcast.
- carolSweep := ht.AssertNumTxsInMempool(1)[0]
-
- // Bob should offer his second level tx to his sweeper.
- ht.AssertNumPendingSweeps(bob, 1)
-
- // Mining one additional block, Bob's second level tx is mature, and he
- // can sweep the output.
- block = ht.MineBlocksAndAssertNumTxes(bobSecondLevelCSV, 1)[0]
- ht.AssertTxInBlock(block, carolSweep)
-
- bobSweep := ht.GetNumTxsFromMempool(1)[0]
- bobSweepTxid := bobSweep.TxHash()
-
- // When we mine one additional block, that will confirm Bob's sweep.
- // Now Bob should have no pending channels anymore, as this just
- // resolved it by the confirmation of the sweep transaction.
- block = ht.MineBlocksAndAssertNumTxes(1, 1)[0]
- ht.AssertTxInBlock(block, bobSweepTxid)
-
- // With the script-enforced lease commitment type, Alice and Bob still
- // haven't been able to sweep their respective commit outputs due to the
- // additional CLTV. We'll need to mine enough blocks for the timelock to
- // expire and prompt their sweep.
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- for _, node := range []*node.HarnessNode{alice, bob} {
- ht.AssertNumPendingForceClose(node, 1)
- }
-
- // Due to the way the test is set up, Alice and Bob share the
- // same CLTV for their commit outputs even though it's enforced
- // on different channels (Alice-Bob and Bob-Carol).
- resp := alice.RPC.PendingChannels()
- require.Len(ht, resp.PendingForceClosingChannels, 1)
- forceCloseChan := resp.PendingForceClosingChannels[0]
- require.Positive(ht, forceCloseChan.BlocksTilMaturity)
-
- // Mine enough blocks for the timelock to expire.
- numBlocks := uint32(forceCloseChan.BlocksTilMaturity)
- ht.MineEmptyBlocks(int(numBlocks))
-
- // Both Alice and Bob should now offer their commit outputs to
- // the sweeper. For Alice, she still has her anchor output as
- // pending sweep as it's not used for CPFP, thus it's
- // uneconomical to sweep it alone.
- ht.AssertNumPendingSweeps(alice, 2)
- ht.AssertNumPendingSweeps(bob, 1)
-
- // Mine a block to trigger the sweeps.
- ht.MineEmptyBlocks(1)
-
- // Both Alice and Bob show broadcast their commit sweeps.
- aliceCommitOutpoint := wire.OutPoint{
- Hash: bobForceClose, Index: 3,
- }
- ht.AssertOutpointInMempool(
- aliceCommitOutpoint,
- ).TxHash()
- bobCommitOutpoint := wire.OutPoint{Hash: closingTxid, Index: 3}
- ht.AssertOutpointInMempool(
- bobCommitOutpoint,
- ).TxHash()
-
- // Confirm their sweeps.
- ht.MineBlocksAndAssertNumTxes(1, 2)
- }
-
- // All nodes should show zero pending and open channels.
- for _, node := range []*node.HarnessNode{alice, bob, carol} {
- ht.AssertNumPendingForceClose(node, 0)
- ht.AssertNodeNumChannels(node, 0)
- }
-
- // Finally, check that the Alice's payment is correctly marked
- // succeeded.
- ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
-}
-
-// testMultiHopHtlcRemoteChainClaim tests that in the multi-hop HTLC scenario,
-// if the remote party goes to chain while we have an incoming HTLC, then when
-// we found out the preimage via the witness beacon, we properly settle the
-// HTLC directly on-chain using the preimage in order to ensure that we don't
-// lose any funds.
-func testMultiHopHtlcRemoteChainClaim(ht *lntest.HarnessTest) {
- runMultiHopHtlcClaimTest(ht, runMultiHopHtlcRemoteChainClaim)
-}
-
-func runMultiHopHtlcRemoteChainClaim(ht *lntest.HarnessTest,
- alice, bob *node.HarnessNode, c lnrpc.CommitmentType, zeroConf bool) {
-
- // First, we'll create a three hop network: Alice -> Bob -> Carol, with
- // Carol refusing to actually settle or directly cancel any HTLC's
- // self.
- aliceChanPoint, bobChanPoint, carol := createThreeHopNetwork(
- ht, alice, bob, false, c, zeroConf,
- )
-
- // If this is a taproot channel, then we'll need to make some manual
- // route hints so Alice can actually find a route.
- var routeHints []*lnrpc.RouteHint
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- routeHints = makeRouteHints(bob, carol, zeroConf)
- }
-
- // With the network active, we'll now add a new hodl invoice at Carol's
- // end. Make sure the cltv expiry delta is large enough, otherwise Bob
- // won't send out the outgoing htlc.
- const invoiceAmt = 100000
- var preimage lntypes.Preimage
- copy(preimage[:], ht.Random32Bytes())
- payHash := preimage.Hash()
- invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
- Value: invoiceAmt,
- CltvExpiry: finalCltvDelta,
- Hash: payHash[:],
- RouteHints: routeHints,
- }
- carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
-
- // Subscribe the invoice.
- stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
-
- // Now that we've created the invoice, we'll send a single payment from
- // Alice to Carol. We won't wait for the response however, as Carol
- // will not immediately settle the payment.
- req := &routerrpc.SendPaymentRequest{
- PaymentRequest: carolInvoice.PaymentRequest,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- }
- alice.RPC.SendPayment(req)
-
- // At this point, all 3 nodes should now have an active channel with
- // the created HTLC pending on all of them.
- ht.AssertActiveHtlcs(alice, payHash[:])
- ht.AssertActiveHtlcs(bob, payHash[:])
- ht.AssertActiveHtlcs(carol, payHash[:])
-
- // Wait for carol to mark invoice as accepted. There is a small gap to
- // bridge between adding the htlc to the channel and executing the exit
- // hop logic.
- ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
-
- // blocksMined records how many blocks have mined after the creation of
- // the invoice so it can be used to calculate how many more blocks need
- // to be mined to trigger a force close later on.
- var blocksMined int
-
- // Lower the fee rate so Bob's two anchor outputs are economical to
- // be swept in one tx.
- ht.SetFeeEstimate(chainfee.FeePerKwFloor)
-
- // Next, Alice decides that she wants to exit the channel, so she'll
- // immediately force close the channel by broadcast her commitment
- // transaction.
- closeStream, _ := ht.CloseChannelAssertPending(
- alice, aliceChanPoint, true,
- )
- aliceForceClose := ht.AssertStreamChannelForceClosed(
- alice, aliceChanPoint, true, closeStream,
- )
-
- // Increase the blocks mined. At this step
- // AssertStreamChannelForceClosed mines one block.
- blocksMined++
-
- // Wait for the channel to be marked pending force close.
- ht.AssertChannelPendingForceClose(alice, aliceChanPoint)
-
- // After AssertStreamChannelForceClosed returns, it has mined a block
- // so now bob will attempt to redeem his anchor output. Check the
- // anchor is offered to the sweeper.
- ht.AssertNumPendingSweeps(bob, 1)
- ht.AssertNumPendingSweeps(alice, 1)
-
- // Mine enough blocks for Alice to sweep her funds from the force
- // closed channel. AssertStreamChannelForceClosed() already mined a
- // block containing the commitment tx and the commit sweep tx will be
- // broadcast immediately before it can be included in a block, so mine
- // one less than defaultCSV in order to perform mempool assertions.
- if c != lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- ht.MineEmptyBlocks(defaultCSV - blocksMined)
- blocksMined = defaultCSV
-
- // Alice should now sweep her funds.
- ht.AssertNumPendingSweeps(alice, 2)
-
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
- blocksMined++
-
- // Mine Alice's commit sweeping tx.
- ht.MineBlocksAndAssertNumTxes(1, 1)
- blocksMined++
- }
-
- // Suspend bob, so Carol is forced to go on chain.
- restartBob := ht.SuspendNode(bob)
-
- // Settle invoice. This will just mark the invoice as settled, as there
- // is no link anymore to remove the htlc from the commitment tx. For
- // this test, it is important to actually settle and not leave the
- // invoice in the accepted state, because without a known preimage, the
- // channel arbitrator won't go to chain.
- carol.RPC.SettleInvoice(preimage[:])
-
- // We'll now mine enough blocks so Carol decides that she needs to go
- // on-chain to claim the HTLC as Bob has been inactive.
- numBlocks := padCLTV(uint32(
- invoiceReq.CltvExpiry - lncfg.DefaultIncomingBroadcastDelta,
- ))
- ht.MineEmptyBlocks(int(numBlocks) - blocksMined)
-
- // Carol's commitment transaction should now be in the mempool.
- ht.AssertNumTxsInMempool(1)
-
- // The closing transaction should be spending from the funding
- // transaction.
- closingTx := ht.AssertOutpointInMempool(
- ht.OutPointFromChannelPoint(bobChanPoint),
- )
- closingTxid := closingTx.TxHash()
-
- // Since Carol has time-sensitive HTLCs, she will use the anchor for
- // CPFP purpose. Assert she has two pending anchor sweep requests - one
- // from local commit and the other from remote commit.
- ht.AssertNumPendingSweeps(carol, 2)
-
- // Mine a block, which should contain: the commitment.
- block := ht.MineBlocksAndAssertNumTxes(1, 1)[0]
- ht.AssertTxInBlock(block, closingTxid)
-
- // After the force close transaction is mined, Carol should offer her
- // second level HTLC tx to the sweeper, along with her anchor output.
- ht.AssertNumPendingSweeps(carol, 2)
-
- // Restart bob again.
- require.NoError(ht, restartBob())
-
- // After the force close transaction is mined, we should expect Bob and
- // Carol to broadcast some transactions depending on the channel
- // commitment type.
- switch c {
- // Carol should broadcast her second level HTLC transaction and Bob
- // should broadcast a sweeping tx to sweep his commitment output and
- // anchor outputs from the two channels.
- case lnrpc.CommitmentType_ANCHORS, lnrpc.CommitmentType_SIMPLE_TAPROOT:
- ht.AssertNumPendingSweeps(bob, 3)
-
- // Carol should broadcast her second level HTLC transaction and Bob
- // should broadcast a transaction to sweep his anchor outputs. Bob
- // can't sweep his commitment output yet as he has incurred an
- // additional CLTV due to being the channel initiator of a force closed
- // script-enforced leased channel.
- case lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE:
- ht.AssertNumPendingSweeps(bob, 2)
-
- default:
- ht.Fatalf("unhandled commitment type %v", c)
- }
-
- // Keep track of the second level tx maturity.
- carolSecondLevelCSV := uint32(defaultCSV)
-
- // Mine a block to trigger the sweeps, also confirms Carol's CPFP
- // anchor sweeping.
- ht.MineBlocksAndAssertNumTxes(1, 1)
- carolSecondLevelCSV--
- ht.AssertNumTxsInMempool(2)
-
- // Mine a block to confirm the expected transactions.
- ht.MineBlocksAndAssertNumTxes(1, 2)
-
- // When Bob notices Carol's second level transaction in the block, he
- // will extract the preimage and offer the HTLC to his sweeper.
- ht.AssertNumPendingSweeps(bob, 1)
-
- // NOTE: after Bob is restarted, the sweeping of the direct preimage
- // spent will happen immediately so we don't need to mine a block to
- // trigger Bob's sweeper to sweep it.
- bobHtlcSweep := ht.GetNumTxsFromMempool(1)[0]
- bobHtlcSweepTxid := bobHtlcSweep.TxHash()
-
- // It should spend from the commitment in the channel with Alice.
- ht.AssertTxSpendFrom(bobHtlcSweep, aliceForceClose)
-
- // We'll now mine a block which should confirm Bob's HTLC sweep
- // transaction.
- block = ht.MineBlocksAndAssertNumTxes(1, 1)[0]
- ht.AssertTxInBlock(block, bobHtlcSweepTxid)
- carolSecondLevelCSV--
-
- // Now that the sweeping transaction has been confirmed, Bob should now
- // recognize that all contracts for the Bob-Carol channel have been
- // fully resolved
- aliceBobPendingChansLeft := 0
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- aliceBobPendingChansLeft = 1
- }
- for _, node := range []*node.HarnessNode{alice, bob} {
- ht.AssertNumPendingForceClose(
- node, aliceBobPendingChansLeft,
- )
- }
-
- // If we then mine 3 additional blocks, Carol's second level tx will
- // mature, and she should pull the funds.
- ht.MineEmptyBlocks(int(carolSecondLevelCSV))
- ht.AssertNumPendingSweeps(carol, 1)
-
- // Mine a block to trigger the sweep of the second level tx.
- ht.MineEmptyBlocks(1)
- carolSweep := ht.AssertNumTxsInMempool(1)[0]
-
- // When Carol's sweep gets confirmed, she should have no more pending
- // channels.
- block = ht.MineBlocksAndAssertNumTxes(1, 1)[0]
- ht.AssertTxInBlock(block, carolSweep)
- ht.AssertNumPendingForceClose(carol, 0)
-
- // With the script-enforced lease commitment type, Alice and Bob still
- // haven't been able to sweep their respective commit outputs due to the
- // additional CLTV. We'll need to mine enough blocks for the timelock to
- // expire and prompt their sweep.
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- // Due to the way the test is set up, Alice and Bob share the
- // same CLTV for their commit outputs even though it's enforced
- // on different channels (Alice-Bob and Bob-Carol).
- resp := alice.RPC.PendingChannels()
- require.Len(ht, resp.PendingForceClosingChannels, 1)
- forceCloseChan := resp.PendingForceClosingChannels[0]
- require.Positive(ht, forceCloseChan.BlocksTilMaturity)
-
- // Mine enough blocks for the timelock to expire.
- numBlocks := int(forceCloseChan.BlocksTilMaturity)
- ht.MineEmptyBlocks(numBlocks)
-
- // Both Alice and Bob should offer their commit sweeps.
- ht.AssertNumPendingSweeps(alice, 2)
- ht.AssertNumPendingSweeps(bob, 1)
-
- // Mine a block to trigger the sweeps.
- ht.MineEmptyBlocks(1)
-
- // Both Alice and Bob should broadcast their commit sweeps.
- aliceCommitOutpoint := wire.OutPoint{
- Hash: aliceForceClose, Index: 3,
- }
- ht.AssertOutpointInMempool(aliceCommitOutpoint)
- bobCommitOutpoint := wire.OutPoint{Hash: closingTxid, Index: 3}
- ht.AssertOutpointInMempool(bobCommitOutpoint)
-
- // Confirm their sweeps.
- ht.MineBlocksAndAssertNumTxes(1, 2)
-
- // Alice and Bob should not show any pending channels anymore as
- // they have been fully resolved.
- for _, node := range []*node.HarnessNode{alice, bob} {
- ht.AssertNumPendingForceClose(node, 0)
- }
- }
-
- // The invoice should show as settled for Carol, indicating that it was
- // swept on-chain.
- invoice := ht.AssertInvoiceState(stream, lnrpc.Invoice_SETTLED)
- require.Equal(ht, int64(invoiceAmt), invoice.AmtPaidSat)
-
- // Finally, check that the Alice's payment is correctly marked
- // succeeded.
- ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
-}
-
-// testMultiHopHtlcAggregation tests that in a multi-hop HTLC scenario, if we
-// force close a channel with both incoming and outgoing HTLCs, we can properly
-// resolve them using the second level timeout and success transactions. In
-// case of anchor channels, the second-level spends can also be aggregated and
-// properly feebumped, so we'll check that as well.
-func testMultiHopHtlcAggregation(ht *lntest.HarnessTest) {
- runMultiHopHtlcClaimTest(ht, runMultiHopHtlcAggregation)
-}
-
-func runMultiHopHtlcAggregation(ht *lntest.HarnessTest,
- alice, bob *node.HarnessNode, c lnrpc.CommitmentType, zeroConf bool) {
-
- // We need one additional UTXO to create the sweeping tx for the
- // second-level success txes.
- ht.FundCoins(btcutil.SatoshiPerBitcoin, bob)
-
- // First, we'll create a three hop network: Alice -> Bob -> Carol.
- aliceChanPoint, bobChanPoint, carol := createThreeHopNetwork(
- ht, alice, bob, false, c, zeroConf,
- )
-
- // If this is a taproot channel, then we'll need to make some manual
- // route hints so Alice+Carol can actually find a route.
- var (
- carolRouteHints []*lnrpc.RouteHint
- aliceRouteHints []*lnrpc.RouteHint
- )
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- carolRouteHints = makeRouteHints(bob, carol, zeroConf)
- aliceRouteHints = makeRouteHints(bob, alice, zeroConf)
- }
-
- // To ensure we have capacity in both directions of the route, we'll
- // make a fairly large payment Alice->Carol and settle it.
- const reBalanceAmt = 500_000
- invoice := &lnrpc.Invoice{
- Value: reBalanceAmt,
- RouteHints: carolRouteHints,
- }
- resp := carol.RPC.AddInvoice(invoice)
- ht.CompletePaymentRequests(alice, []string{resp.PaymentRequest})
-
- // Make sure Carol has settled the invoice.
- ht.AssertInvoiceSettled(carol, resp.PaymentAddr)
-
- // With the network active, we'll now add a new hodl invoices at both
- // Alice's and Carol's end. Make sure the cltv expiry delta is large
- // enough, otherwise Bob won't send out the outgoing htlc.
- const numInvoices = 5
- const invoiceAmt = 50_000
-
- var (
- carolInvoices []*invoicesrpc.AddHoldInvoiceResp
- aliceInvoices []*invoicesrpc.AddHoldInvoiceResp
- alicePreimages []lntypes.Preimage
- payHashes [][]byte
- invoiceStreamsCarol []rpc.SingleInvoiceClient
- invoiceStreamsAlice []rpc.SingleInvoiceClient
- )
-
- // Add Carol invoices.
- for i := 0; i < numInvoices; i++ {
- var preimage lntypes.Preimage
- copy(preimage[:], ht.Random32Bytes())
- payHash := preimage.Hash()
- invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
- Value: invoiceAmt,
- CltvExpiry: finalCltvDelta,
- Hash: payHash[:],
- RouteHints: carolRouteHints,
- }
- carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
-
- carolInvoices = append(carolInvoices, carolInvoice)
- payHashes = append(payHashes, payHash[:])
-
- // Subscribe the invoice.
- stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
- invoiceStreamsCarol = append(invoiceStreamsCarol, stream)
- }
-
- // We'll give Alice's invoices a longer CLTV expiry, to ensure the
- // channel Bob<->Carol will be closed first.
- for i := 0; i < numInvoices; i++ {
- var preimage lntypes.Preimage
- copy(preimage[:], ht.Random32Bytes())
- payHash := preimage.Hash()
- invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
- Value: invoiceAmt,
- CltvExpiry: thawHeightDelta - 4,
- Hash: payHash[:],
- RouteHints: aliceRouteHints,
- }
- aliceInvoice := alice.RPC.AddHoldInvoice(invoiceReq)
-
- aliceInvoices = append(aliceInvoices, aliceInvoice)
- alicePreimages = append(alicePreimages, preimage)
- payHashes = append(payHashes, payHash[:])
-
- // Subscribe the invoice.
- stream := alice.RPC.SubscribeSingleInvoice(payHash[:])
- invoiceStreamsAlice = append(invoiceStreamsAlice, stream)
- }
-
- // Now that we've created the invoices, we'll pay them all from
- // Alice<->Carol, going through Bob. We won't wait for the response
- // however, as neither will immediately settle the payment.
-
- // Alice will pay all of Carol's invoices.
- for _, carolInvoice := range carolInvoices {
- req := &routerrpc.SendPaymentRequest{
- PaymentRequest: carolInvoice.PaymentRequest,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- }
- alice.RPC.SendPayment(req)
- }
-
- // And Carol will pay Alice's.
- for _, aliceInvoice := range aliceInvoices {
- req := &routerrpc.SendPaymentRequest{
- PaymentRequest: aliceInvoice.PaymentRequest,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- }
- carol.RPC.SendPayment(req)
- }
-
- // At this point, all 3 nodes should now the HTLCs active on their
- // channels.
- ht.AssertActiveHtlcs(alice, payHashes...)
- ht.AssertActiveHtlcs(bob, payHashes...)
- ht.AssertActiveHtlcs(carol, payHashes...)
-
- // Wait for Alice and Carol to mark the invoices as accepted. There is
- // a small gap to bridge between adding the htlc to the channel and
- // executing the exit hop logic.
- for _, stream := range invoiceStreamsCarol {
- ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
- }
-
- for _, stream := range invoiceStreamsAlice {
- ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
- }
-
- // Increase the fee estimate so that the following force close tx will
- // be cpfp'ed.
- ht.SetFeeEstimate(30000)
-
- // We want Carol's htlcs to expire off-chain to demonstrate bob's force
- // close. However, Carol will cancel her invoices to prevent force
- // closes, so we shut her down for now.
- restartCarol := ht.SuspendNode(carol)
-
- // We'll now mine enough blocks to trigger Bob's broadcast of his
- // commitment transaction due to the fact that the Carol's HTLCs are
- // about to timeout. With the default outgoing broadcast delta of zero,
- // this will be the same height as the htlc expiry height.
- numBlocks := padCLTV(
- uint32(finalCltvDelta - lncfg.DefaultOutgoingBroadcastDelta),
- )
- ht.MineEmptyBlocks(int(numBlocks))
-
- // Bob's force close transaction should now be found in the mempool. If
- // there are anchors, we expect it to be offered to Bob's sweeper.
- ht.AssertNumTxsInMempool(1)
-
- // Bob has two anchor sweep requests, one for remote (invalid) and the
- // other for local.
- ht.AssertNumPendingSweeps(bob, 2)
-
- closeTx := ht.AssertOutpointInMempool(
- ht.OutPointFromChannelPoint(bobChanPoint),
- )
- closeTxid := closeTx.TxHash()
-
- // Go through the closing transaction outputs, and make an index for
- // the HTLC outputs.
- successOuts := make(map[wire.OutPoint]struct{})
- timeoutOuts := make(map[wire.OutPoint]struct{})
- for i, txOut := range closeTx.TxOut {
- op := wire.OutPoint{
- Hash: closeTxid,
- Index: uint32(i),
- }
-
- switch txOut.Value {
- // If this HTLC goes towards Carol, Bob will claim it with a
- // timeout Tx. In this case the value will be the invoice
- // amount.
- case invoiceAmt:
- timeoutOuts[op] = struct{}{}
-
- // If the HTLC has direction towards Alice, Bob will claim it
- // with the success TX when he learns the preimage. In this
- // case one extra sat will be on the output, because of the
- // routing fee.
- case invoiceAmt + 1:
- successOuts[op] = struct{}{}
- }
- }
-
- // Once bob has force closed, we can restart carol.
- require.NoError(ht, restartCarol())
-
- // Mine a block to confirm the closing transaction.
- ht.MineBlocksAndAssertNumTxes(1, 1)
-
- // The above mined block will trigger Bob to sweep his anchor output.
- ht.AssertNumTxsInMempool(1)
-
- // Let Alice settle her invoices. When Bob now gets the preimages, he
- // has no other option than to broadcast his second-level transactions
- // to claim the money.
- for _, preimage := range alicePreimages {
- alice.RPC.SettleInvoice(preimage[:])
- }
-
- expectedTxes := 0
- switch c {
- // In case of anchors, all success transactions will be aggregated into
- // one, the same is the case for the timeout transactions. In this case
- // Carol will also sweep her commitment and anchor output in a single
- // tx.
- case lnrpc.CommitmentType_ANCHORS,
- lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE,
- lnrpc.CommitmentType_SIMPLE_TAPROOT:
-
- // Bob should have `numInvoices` for both HTLC success and
- // timeout txns, plus one anchor sweep.
- ht.AssertNumPendingSweeps(bob, numInvoices*2+1)
-
- // Carol should have commit and anchor outputs.
- ht.AssertNumPendingSweeps(carol, 2)
-
- // We expect to see three sweeping txns:
- // 1. Bob's sweeping tx for all timeout HTLCs.
- // 2. Bob's sweeping tx for all success HTLCs.
- // 3. Carol's sweeping tx for her commit and anchor outputs.
- expectedTxes = 3
-
- default:
- ht.Fatalf("unhandled commitment type %v", c)
- }
-
- // Mine a block to confirm Bob's anchor sweeping, which will also
- // trigger his sweeper to sweep HTLCs.
- ht.MineBlocksAndAssertNumTxes(1, 1)
-
- // Assert the sweeping txns are found in the mempool.
- txes := ht.GetNumTxsFromMempool(expectedTxes)
-
- // Since Bob can aggregate the transactions, we expect a single
- // transaction, that have multiple spends from the commitment.
- var (
- timeoutTxs []*chainhash.Hash
- successTxs []*chainhash.Hash
- )
- for _, tx := range txes {
- txid := tx.TxHash()
-
- for i := range tx.TxIn {
- prevOp := tx.TxIn[i].PreviousOutPoint
- if _, ok := successOuts[prevOp]; ok {
- successTxs = append(successTxs, &txid)
-
- break
- }
-
- if _, ok := timeoutOuts[prevOp]; ok {
- timeoutTxs = append(timeoutTxs, &txid)
-
- break
- }
- }
- }
-
- // In case of anchor we expect all the timeout and success second
- // levels to be aggregated into one tx. For earlier channel types, they
- // will be separate transactions.
- if lntest.CommitTypeHasAnchors(c) {
- require.Len(ht, timeoutTxs, 1)
- require.Len(ht, successTxs, 1)
- } else {
- require.Len(ht, timeoutTxs, numInvoices)
- require.Len(ht, successTxs, numInvoices)
- }
-
- // All mempool transactions should be spending from the commitment
- // transaction.
- ht.AssertAllTxesSpendFrom(txes, closeTxid)
-
- // Mine a block to confirm the all the transactions, including Carol's
- // commitment tx, anchor tx(optional), and Bob's second-level timeout
- // and success txes.
- ht.MineBlocksAndAssertNumTxes(1, expectedTxes)
-
- // At this point, Bob should have broadcast his second layer success
- // transaction, and should have sent it to the nursery for incubation,
- // or to the sweeper for sweeping.
- forceCloseChan := ht.AssertNumPendingForceClose(bob, 1)[0]
- ht.Logf("Bob's timelock on commit=%v, timelock on htlc=%v",
- forceCloseChan.BlocksTilMaturity,
- forceCloseChan.PendingHtlcs[0].BlocksTilMaturity)
-
- // For this channel, we also check the number of HTLCs and the stage
- // are correct.
- ht.AssertNumHTLCsAndStage(bob, bobChanPoint, numInvoices*2, 2)
-
- if c != lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- // If we then mine additional blocks, Bob can sweep his
- // commitment output.
- ht.MineEmptyBlocks(1)
-
- // Assert the tx has been offered to the sweeper.
- ht.AssertNumPendingSweeps(bob, 1)
-
- // Mine one block to trigger the sweep.
- ht.MineEmptyBlocks(1)
-
- // Find the commitment sweep.
- bobCommitSweep := ht.GetNumTxsFromMempool(1)[0]
- ht.AssertTxSpendFrom(bobCommitSweep, closeTxid)
-
- // Also ensure it is not spending from any of the HTLC output.
- for _, txin := range bobCommitSweep.TxIn {
- for _, timeoutTx := range timeoutTxs {
- require.NotEqual(ht, *timeoutTx,
- txin.PreviousOutPoint.Hash,
- "found unexpected spend of timeout tx")
- }
-
- for _, successTx := range successTxs {
- require.NotEqual(ht, *successTx,
- txin.PreviousOutPoint.Hash,
- "found unexpected spend of success tx")
- }
- }
- }
-
- switch c {
- // Mining one additional block, Bob's second level tx is mature, and he
- // can sweep the output. Before the blocks are mined, we should expect
- // to see Bob's commit sweep in the mempool.
- case lnrpc.CommitmentType_ANCHORS, lnrpc.CommitmentType_SIMPLE_TAPROOT:
- ht.MineBlocksAndAssertNumTxes(1, 1)
-
- // Since Bob is the initiator of the Bob-Carol script-enforced leased
- // channel, he incurs an additional CLTV when sweeping outputs back to
- // his wallet. We'll need to mine enough blocks for the timelock to
- // expire to prompt his broadcast.
- case lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE:
- resp := bob.RPC.PendingChannels()
- require.Len(ht, resp.PendingForceClosingChannels, 1)
- forceCloseChan := resp.PendingForceClosingChannels[0]
- require.Positive(ht, forceCloseChan.BlocksTilMaturity)
- numBlocks := uint32(forceCloseChan.BlocksTilMaturity)
-
- // Add debug log.
- height := ht.CurrentHeight()
- bob.AddToLogf("itest: now mine %d blocks at height %d",
- numBlocks, height)
- ht.MineEmptyBlocks(int(numBlocks) - 1)
-
- default:
- ht.Fatalf("unhandled commitment type %v", c)
- }
-
- // Make sure Bob's sweeper has received all the sweeping requests.
- ht.AssertNumPendingSweeps(bob, numInvoices*2)
-
- // Mine one block to trigger the sweeps.
- ht.MineEmptyBlocks(1)
-
- // For leased channels, Bob's commit output will mature after the above
- // block.
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- ht.AssertNumPendingSweeps(bob, numInvoices*2+1)
- }
-
- // We now wait for 30 seconds to overcome the flake - there's a block
- // race between contractcourt and sweeper, causing the sweep to be
- // broadcast earlier.
- //
- // TODO(yy): remove this once `blockbeat` is in place.
- numExpected := 1
- err := wait.NoError(func() error {
- mem := ht.GetRawMempool()
- if len(mem) == numExpected {
- return nil
- }
-
- if len(mem) > 0 {
- numExpected = len(mem)
- }
-
- return fmt.Errorf("want %d, got %v in mempool: %v", numExpected,
- len(mem), mem)
- }, wait.DefaultTimeout)
- ht.Logf("Checking mempool got: %v", err)
-
- // Make sure it spends from the second level tx.
- secondLevelSweep := ht.GetNumTxsFromMempool(numExpected)[0]
- bobSweep := secondLevelSweep.TxHash()
-
- // It should be sweeping all the second-level outputs.
- var secondLvlSpends int
- for _, txin := range secondLevelSweep.TxIn {
- for _, timeoutTx := range timeoutTxs {
- if *timeoutTx == txin.PreviousOutPoint.Hash {
- secondLvlSpends++
- }
- }
-
- for _, successTx := range successTxs {
- if *successTx == txin.PreviousOutPoint.Hash {
- secondLvlSpends++
- }
- }
- }
-
- // TODO(yy): bring the following check back when `blockbeat` is in
- // place - atm we may have two sweeping transactions in the mempool.
- // require.Equal(ht, 2*numInvoices, secondLvlSpends)
-
- // When we mine one additional block, that will confirm Bob's second
- // level sweep. Now Bob should have no pending channels anymore, as
- // this just resolved it by the confirmation of the sweep transaction.
- block := ht.MineBlocksAndAssertNumTxes(1, numExpected)[0]
- ht.AssertTxInBlock(block, bobSweep)
-
- // For leased channels, we need to mine one more block to confirm Bob's
- // commit output sweep.
- //
- // NOTE: we mine this block conditionally, as the commit output may
- // have already been swept one block earlier due to the race in block
- // consumption among subsystems.
- pendingChanResp := bob.RPC.PendingChannels()
- if len(pendingChanResp.PendingForceClosingChannels) != 0 {
- ht.MineBlocksAndAssertNumTxes(1, 1)
- }
- ht.AssertNumPendingForceClose(bob, 0)
-
- // THe channel with Alice is still open.
- ht.AssertNodeNumChannels(bob, 1)
-
- // Carol should have no channels left (open nor pending).
- ht.AssertNumPendingForceClose(carol, 0)
- ht.AssertNodeNumChannels(carol, 0)
-
- // Coop close, no anchors.
- ht.CloseChannel(alice, aliceChanPoint)
-}
-
-// createThreeHopNetwork creates a topology of `Alice -> Bob -> Carol`.
-func createThreeHopNetwork(ht *lntest.HarnessTest,
- alice, bob *node.HarnessNode, carolHodl bool, c lnrpc.CommitmentType,
- zeroConf bool) (*lnrpc.ChannelPoint,
- *lnrpc.ChannelPoint, *node.HarnessNode) {
-
- ht.EnsureConnected(alice, bob)
-
- // We'll create a new node "carol" and have Bob connect to her.
- // If the carolHodl flag is set, we'll make carol always hold onto the
- // HTLC, this way it'll force Bob to go to chain to resolve the HTLC.
- carolFlags := lntest.NodeArgsForCommitType(c)
- if carolHodl {
- carolFlags = append(carolFlags, "--hodl.exit-settle")
- }
-
- if zeroConf {
- carolFlags = append(
- carolFlags, "--protocol.option-scid-alias",
- "--protocol.zero-conf",
- )
- }
- carol := ht.NewNode("Carol", carolFlags)
-
- ht.ConnectNodes(bob, carol)
-
- // Make sure there are enough utxos for anchoring. Because the anchor
- // by itself often doesn't meet the dust limit, a utxo from the wallet
- // needs to be attached as an additional input. This can still lead to
- // a positively-yielding transaction.
- for i := 0; i < 2; i++ {
- ht.FundCoinsUnconfirmed(btcutil.SatoshiPerBitcoin, alice)
- ht.FundCoinsUnconfirmed(btcutil.SatoshiPerBitcoin, bob)
- ht.FundCoinsUnconfirmed(btcutil.SatoshiPerBitcoin, carol)
-
- // Mine 1 block to get the above coins confirmed.
- ht.MineBlocksAndAssertNumTxes(1, 3)
- }
-
- // We'll start the test by creating a channel between Alice and Bob,
- // which will act as the first leg for out multi-hop HTLC.
- const chanAmt = 1000000
- var aliceFundingShim *lnrpc.FundingShim
- var thawHeight uint32
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- minerHeight := ht.CurrentHeight()
- thawHeight = minerHeight + thawHeightDelta
- aliceFundingShim, _ = deriveFundingShim(
- ht, alice, bob, chanAmt, thawHeight, true, c,
- )
- }
-
- var privateChan bool
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- privateChan = true
- }
-
- aliceParams := lntest.OpenChannelParams{
- Private: privateChan,
- Amt: chanAmt,
- CommitmentType: c,
- FundingShim: aliceFundingShim,
- ZeroConf: zeroConf,
- }
-
- // If the channel type is taproot, then use an explicit channel type to
- // open it.
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- aliceParams.CommitmentType = lnrpc.CommitmentType_SIMPLE_TAPROOT
- }
-
- // We'll create a channel from Bob to Carol. After this channel is
- // open, our topology looks like: A -> B -> C.
- var bobFundingShim *lnrpc.FundingShim
- if c == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- bobFundingShim, _ = deriveFundingShim(
- ht, bob, carol, chanAmt, thawHeight, true, c,
- )
- }
-
- // Prepare params for Bob.
- bobParams := lntest.OpenChannelParams{
- Amt: chanAmt,
- Private: privateChan,
- CommitmentType: c,
- FundingShim: bobFundingShim,
- ZeroConf: zeroConf,
- }
-
- // If the channel type is taproot, then use an explicit channel type to
- // open it.
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- bobParams.CommitmentType = lnrpc.CommitmentType_SIMPLE_TAPROOT
- }
-
- var (
- acceptStreamBob rpc.AcceptorClient
- acceptStreamCarol rpc.AcceptorClient
- cancelBob context.CancelFunc
- cancelCarol context.CancelFunc
- )
-
- // If a zero-conf channel is being opened, the nodes are signalling the
- // zero-conf feature bit. Setup a ChannelAcceptor for the fundee.
- if zeroConf {
- acceptStreamBob, cancelBob = bob.RPC.ChannelAcceptor()
- go acceptChannel(ht.T, true, acceptStreamBob)
-
- acceptStreamCarol, cancelCarol = carol.RPC.ChannelAcceptor()
- go acceptChannel(ht.T, true, acceptStreamCarol)
- }
-
- // Open channels in batch to save blocks mined.
- reqs := []*lntest.OpenChannelRequest{
- {Local: alice, Remote: bob, Param: aliceParams},
- {Local: bob, Remote: carol, Param: bobParams},
- }
- resp := ht.OpenMultiChannelsAsync(reqs)
- aliceChanPoint := resp[0]
- bobChanPoint := resp[1]
-
- // Make sure alice and carol know each other's channels.
- //
- // We'll only do this though if it wasn't a private channel we opened
- // earlier.
- if !privateChan {
- ht.AssertChannelInGraph(alice, bobChanPoint)
- ht.AssertChannelInGraph(carol, aliceChanPoint)
- } else {
- // Otherwise, we want to wait for all the channels to be shown
- // as active before we proceed.
- ht.AssertChannelExists(alice, aliceChanPoint)
- ht.AssertChannelExists(carol, bobChanPoint)
- }
-
- // Remove the ChannelAcceptor for Bob and Carol.
- if zeroConf {
- cancelBob()
- cancelCarol()
- }
-
- return aliceChanPoint, bobChanPoint, carol
-}
-
-// testHtlcTimeoutResolverExtractPreimageRemote tests that in the multi-hop
-// setting, Alice->Bob->Carol, when Bob's outgoing HTLC is swept by Carol using
-// the 2nd level success tx2nd level success tx, Bob's timeout resolver will
-// extract the preimage from the sweep tx found in mempool or blocks(for
-// neutrino). The 2nd level success tx is broadcast by Carol and spends the
-// outpoint on her commit tx.
-func testHtlcTimeoutResolverExtractPreimageRemote(ht *lntest.HarnessTest) {
- runMultiHopHtlcClaimTest(ht, runExtraPreimageFromRemoteCommit)
-}
-
-// runExtraPreimageFromRemoteCommit checks that Bob's htlc timeout resolver
-// will extract the preimage from the 2nd level success tx broadcast by Carol
-// which spends the htlc output on her commitment tx.
-func runExtraPreimageFromRemoteCommit(ht *lntest.HarnessTest,
- alice, bob *node.HarnessNode, c lnrpc.CommitmentType, zeroConf bool) {
-
- // First, we'll create a three hop network: Alice -> Bob -> Carol, with
- // Carol refusing to actually settle or directly cancel any HTLC's
- // self.
- aliceChanPoint, bobChanPoint, carol := createThreeHopNetwork(
- ht, alice, bob, false, c, zeroConf,
- )
-
- if ht.IsNeutrinoBackend() {
- ht.FundCoins(btcutil.SatoshiPerBitcoin, carol)
- }
-
- // If this is a taproot channel, then we'll need to make some manual
- // route hints so Alice can actually find a route.
- var routeHints []*lnrpc.RouteHint
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- routeHints = makeRouteHints(bob, carol, zeroConf)
- }
-
- // With the network active, we'll now add a new hodl invoice at Carol's
- // end. Make sure the cltv expiry delta is large enough, otherwise Bob
- // won't send out the outgoing htlc.
- preimage := ht.RandomPreimage()
- payHash := preimage.Hash()
- invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
- Value: 100_000,
- CltvExpiry: finalCltvDelta,
- Hash: payHash[:],
- RouteHints: routeHints,
- }
- eveInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
-
- // Subscribe the invoice.
- stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
-
- // Now that we've created the invoice, we'll send a single payment from
- // Alice to Carol. We won't wait for the response however, as Carol
- // will not immediately settle the payment.
- req := &routerrpc.SendPaymentRequest{
- PaymentRequest: eveInvoice.PaymentRequest,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- }
- alice.RPC.SendPayment(req)
-
- // Once the payment sent, Alice should have one outgoing HTLC active.
- ht.AssertOutgoingHTLCActive(alice, aliceChanPoint, payHash[:])
-
- // Bob should have two HTLCs active. One incoming HTLC from Alice, and
- // one outgoing to Carol.
- ht.AssertIncomingHTLCActive(bob, aliceChanPoint, payHash[:])
- htlc := ht.AssertOutgoingHTLCActive(bob, bobChanPoint, payHash[:])
-
- // Carol should have one incoming HTLC from Bob.
- ht.AssertIncomingHTLCActive(carol, bobChanPoint, payHash[:])
-
- // Wait for Carol to mark invoice as accepted. There is a small gap to
- // bridge between adding the htlc to the channel and executing the exit
- // hop logic.
- ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
-
- // Bob now goes offline so the link between Bob and Carol is broken.
- restartBob := ht.SuspendNode(bob)
-
- // Carol now settles the invoice, since her link with Bob is broken,
- // Bob won't know the preimage.
- carol.RPC.SettleInvoice(preimage[:])
-
- // We'll now mine enough blocks to trigger Carol's broadcast of her
- // commitment transaction due to the fact that the HTLC is about to
- // timeout. With the default incoming broadcast delta of 10, this
- // will be the htlc expiry height minus 10.
- numBlocks := padCLTV(uint32(
- invoiceReq.CltvExpiry - lncfg.DefaultIncomingBroadcastDelta,
- ))
- ht.MineEmptyBlocks(int(numBlocks))
-
- // Carol's force close transaction should now be found in the mempool.
- // If there are anchors, we also expect Carol's contractcourt to offer
- // the anchors to her sweeper - one from the local commitment and the
- // other from the remote.
- ht.AssertNumPendingSweeps(carol, 2)
-
- // We now mine a block to confirm Carol's closing transaction, which
- // will trigger her sweeper to sweep her CPFP anchor sweeping.
- ht.MineClosingTx(bobChanPoint)
-
- // With the closing transaction confirmed, we should expect Carol's
- // HTLC success transaction to be offered to the sweeper along with her
- // anchor output.
- ht.AssertNumPendingSweeps(carol, 2)
-
- // Mine a block to trigger the sweep, and clean up the anchor sweeping
- // tx.
- ht.MineBlocksAndAssertNumTxes(1, 1)
- ht.AssertNumTxsInMempool(1)
-
- // Restart Bob. Once he finishes syncing the channel state, he should
- // notice the force close from Carol.
- require.NoError(ht, restartBob())
-
- // Get the current height to compute number of blocks to mine to
- // trigger the htlc timeout resolver from Bob.
- height := ht.CurrentHeight()
-
- // We'll now mine enough blocks to trigger Bob's timeout resolver.
- numBlocks = htlc.ExpirationHeight - height -
- lncfg.DefaultOutgoingBroadcastDelta
-
- // We should now have Carol's htlc success tx in the mempool.
- numTxesMempool := 1
- ht.AssertNumTxsInMempool(numTxesMempool)
-
- // For neutrino backend, the timeout resolver needs to extract the
- // preimage from the blocks.
- if ht.IsNeutrinoBackend() {
- // Mine a block to confirm Carol's 2nd level success tx.
- ht.MineBlocksAndAssertNumTxes(1, 1)
- numBlocks--
- }
-
- // Mine empty blocks so Carol's htlc success tx stays in mempool. Once
- // the height is reached, Bob's timeout resolver will resolve the htlc
- // by extracing the preimage from the mempool.
- ht.MineEmptyBlocks(int(numBlocks))
-
- // Finally, check that the Alice's payment is marked as succeeded as
- // Bob has settled the htlc using the preimage extracted from Carol's
- // 2nd level success tx.
- ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
-
- switch c {
- // For anchor channel type, we should expect to see Bob's commit output
- // and his anchor output be swept in a single tx in the mempool.
- case lnrpc.CommitmentType_ANCHORS, lnrpc.CommitmentType_SIMPLE_TAPROOT:
- numTxesMempool++
-
- // For script-enforced leased channel, Bob's anchor sweep tx won't
- // happen as it's not used for CPFP, hence no wallet utxo is used so
- // it'll be uneconomical.
- case lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE:
- }
-
- // For neutrino backend, Carol's second-stage sweep should be offered
- // to her sweeper.
- if ht.IsNeutrinoBackend() {
- ht.AssertNumPendingSweeps(carol, 1)
-
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
- }
-
- // Mine a block to clean the mempool.
- ht.MineBlocksAndAssertNumTxes(1, numTxesMempool)
-
- // NOTE: for non-standby nodes there's no need to clean up the force
- // close as long as the mempool is cleaned.
- ht.CleanShutDown()
-}
-
-// testHtlcTimeoutResolverExtractPreimage tests that in the multi-hop setting,
-// Alice->Bob->Carol, when Bob's outgoing HTLC is swept by Carol using the
-// direct preimage spend, Bob's timeout resolver will extract the preimage from
-// the sweep tx found in mempool or blocks(for neutrino). The direct spend tx
-// is broadcast by Carol and spends the outpoint on Bob's commit tx.
-func testHtlcTimeoutResolverExtractPreimageLocal(ht *lntest.HarnessTest) {
- runMultiHopHtlcClaimTest(ht, runExtraPreimageFromLocalCommit)
-}
-
-// runExtraPreimageFromLocalCommit checks that Bob's htlc timeout resolver will
-// extract the preimage from the direct spend broadcast by Carol which spends
-// the htlc output on Bob's commitment tx.
-func runExtraPreimageFromLocalCommit(ht *lntest.HarnessTest,
- alice, bob *node.HarnessNode, c lnrpc.CommitmentType, zeroConf bool) {
-
- // First, we'll create a three hop network: Alice -> Bob -> Carol, with
- // Carol refusing to actually settle or directly cancel any HTLC's
- // self.
- aliceChanPoint, bobChanPoint, carol := createThreeHopNetwork(
- ht, alice, bob, false, c, zeroConf,
- )
-
- // If this is a taproot channel, then we'll need to make some manual
- // route hints so Alice can actually find a route.
- var routeHints []*lnrpc.RouteHint
- if c == lnrpc.CommitmentType_SIMPLE_TAPROOT {
- routeHints = makeRouteHints(bob, carol, zeroConf)
- }
-
- // With the network active, we'll now add a new hodl invoice at Carol's
- // end. Make sure the cltv expiry delta is large enough, otherwise Bob
- // won't send out the outgoing htlc.
- preimage := ht.RandomPreimage()
- payHash := preimage.Hash()
- invoiceReq := &invoicesrpc.AddHoldInvoiceRequest{
- Value: 100_000,
- CltvExpiry: finalCltvDelta,
- Hash: payHash[:],
- RouteHints: routeHints,
- }
- carolInvoice := carol.RPC.AddHoldInvoice(invoiceReq)
-
- // Subscribe the invoice.
- stream := carol.RPC.SubscribeSingleInvoice(payHash[:])
-
- // Now that we've created the invoice, we'll send a single payment from
- // Alice to Carol. We won't wait for the response however, as Carol
- // will not immediately settle the payment.
- req := &routerrpc.SendPaymentRequest{
- PaymentRequest: carolInvoice.PaymentRequest,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- }
- alice.RPC.SendPayment(req)
-
- // Once the payment sent, Alice should have one outgoing HTLC active.
- ht.AssertOutgoingHTLCActive(alice, aliceChanPoint, payHash[:])
-
- // Bob should have two HTLCs active. One incoming HTLC from Alice, and
- // one outgoing to Carol.
- ht.AssertIncomingHTLCActive(bob, aliceChanPoint, payHash[:])
- htlc := ht.AssertOutgoingHTLCActive(bob, bobChanPoint, payHash[:])
-
- // Carol should have one incoming HTLC from Bob.
- ht.AssertIncomingHTLCActive(carol, bobChanPoint, payHash[:])
-
- // Wait for Carol to mark invoice as accepted. There is a small gap to
- // bridge between adding the htlc to the channel and executing the exit
- // hop logic.
- ht.AssertInvoiceState(stream, lnrpc.Invoice_ACCEPTED)
-
- // Bob now goes offline so the link between Bob and Carol is broken.
- restartBob := ht.SuspendNode(bob)
-
- // Carol now settles the invoice, since her link with Bob is broken,
- // Bob won't know the preimage.
- carol.RPC.SettleInvoice(preimage[:])
-
- // Stop Carol so it's easier to check the mempool's state since she
- // will broadcast the anchor sweeping once Bob force closes.
- restartCarol := ht.SuspendNode(carol)
-
- // Restart Bob to force close the channel.
- require.NoError(ht, restartBob())
-
- // Bob force closes the channel, which gets his commitment tx into the
- // mempool.
- ht.CloseChannelAssertPending(bob, bobChanPoint, true)
-
- // Bob should now has offered his anchors to his sweeper - both local
- // and remote versions.
- ht.AssertNumPendingSweeps(bob, 2)
-
- // Mine Bob's force close tx.
- closeTx := ht.MineClosingTx(bobChanPoint)
-
- // Mine Bob's anchor sweeping tx.
- ht.MineBlocksAndAssertNumTxes(1, 1)
- blocksMined := 1
-
- // We'll now mine enough blocks to trigger Carol's sweeping of the htlc
- // via the direct spend. With the default incoming broadcast delta of
- // 10, this will be the htlc expiry height minus 10.
- //
- // NOTE: we need to mine 1 fewer block as we've already mined one to
- // confirm Bob's force close tx.
- numBlocks := padCLTV(uint32(
- invoiceReq.CltvExpiry - lncfg.DefaultIncomingBroadcastDelta - 1,
- ))
-
- // If this is a nont script-enforced channel, Bob will be able to sweep
- // his commit output after 4 blocks.
- if c != lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- // Mine 3 blocks so the output will be offered to the sweeper.
- ht.MineEmptyBlocks(defaultCSV - blocksMined - 1)
-
- // Assert the commit output has been offered to the sweeper.
- ht.AssertNumPendingSweeps(bob, 1)
-
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
- blocksMined = defaultCSV
- }
-
- // Mine empty blocks so it's easier to check Bob's sweeping txes below.
- ht.MineEmptyBlocks(int(numBlocks) - blocksMined)
-
- // With the above blocks mined, we should expect Carol's to offer the
- // htlc output on Bob's commitment to the sweeper.
- //
- // TODO(yy): it's not offered to the sweeper yet, instead, the utxo
- // nursery is creating and broadcasting the sweep tx - we should unify
- // this behavior and offer it to the sweeper.
- // ht.AssertNumPendingSweeps(carol, 1)
-
- // Increase the fee rate used by the sweeper so Carol's direct spend tx
- // won't be replaced by Bob's timeout tx.
- ht.SetFeeEstimate(30000)
-
- // Restart Carol to sweep the htlc output.
- require.NoError(ht, restartCarol())
-
- ht.AssertNumPendingSweeps(carol, 2)
- ht.MineEmptyBlocks(1)
-
- // Construct the htlc output on Bob's commitment tx, and decide its
- // index based on the commit type below.
- htlcOutpoint := wire.OutPoint{Hash: closeTx.TxHash()}
-
- // Check the current mempool state and we should see,
- // - Carol's direct spend tx.
- // - Bob's local output sweep tx, if this is NOT script enforced lease.
- // - Carol's anchor sweep tx cannot be broadcast as it's uneconomical.
- switch c {
- case lnrpc.CommitmentType_ANCHORS, lnrpc.CommitmentType_SIMPLE_TAPROOT:
- htlcOutpoint.Index = 2
- ht.AssertNumTxsInMempool(2)
-
- case lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE:
- htlcOutpoint.Index = 2
- ht.AssertNumTxsInMempool(1)
- }
-
- // Get the current height to compute number of blocks to mine to
- // trigger the timeout resolver from Bob.
- height := ht.CurrentHeight()
-
- // We'll now mine enough blocks to trigger Bob's htlc timeout resolver
- // to act. Once his timeout resolver starts, it will extract the
- // preimage from Carol's direct spend tx found in the mempool.
- numBlocks = htlc.ExpirationHeight - height -
- lncfg.DefaultOutgoingBroadcastDelta
-
- // Decrease the fee rate used by the sweeper so Bob's timeout tx will
- // not replace Carol's direct spend tx.
- ht.SetFeeEstimate(1000)
-
- // Mine empty blocks so Carol's direct spend tx stays in mempool. Once
- // the height is reached, Bob's timeout resolver will resolve the htlc
- // by extracing the preimage from the mempool.
- ht.MineEmptyBlocks(int(numBlocks))
-
- // For neutrino backend, the timeout resolver needs to extract the
- // preimage from the blocks.
- if ht.IsNeutrinoBackend() {
- // Make sure the direct spend tx is still in the mempool.
- ht.AssertOutpointInMempool(htlcOutpoint)
-
- // Mine a block to confirm two txns,
- // - Carol's direct spend tx.
- // - Bob's to_local output sweep tx.
- if c != lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE {
- ht.MineBlocksAndAssertNumTxes(1, 2)
- } else {
- ht.MineBlocksAndAssertNumTxes(1, 1)
- }
- }
-
- // Finally, check that the Alice's payment is marked as succeeded as
- // Bob has settled the htlc using the preimage extracted from Carol's
- // direct spend tx.
- ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED)
-
- // NOTE: for non-standby nodes there's no need to clean up the force
- // close as long as the mempool is cleaned.
- ht.CleanShutDown()
-}
diff --git a/itest/lnd_payment_test.go b/itest/lnd_payment_test.go
index 3515cbc946a..4df148d95e6 100644
--- a/itest/lnd_payment_test.go
+++ b/itest/lnd_payment_test.go
@@ -135,22 +135,32 @@ func testPaymentSucceededHTLCRemoteSwept(ht *lntest.HarnessTest) {
// direct preimage spend.
ht.AssertNumPendingSweeps(bob, 1)
- // Mine a block to trigger the sweep.
- //
- // TODO(yy): remove it once `blockbeat` is implemented.
- ht.MineEmptyBlocks(1)
-
- // Mine Bob's sweeping tx.
- ht.MineBlocksAndAssertNumTxes(1, 1)
-
// Let Alice come back up. Since the channel is now closed, we expect
// different behaviors based on whether the HTLC is a dust.
// - For dust payment, it should be failed now as the HTLC won't go
// onchain.
// - For non-dust payment, it should be marked as succeeded since her
// outgoing htlc is swept by Bob.
+ //
+ // TODO(yy): move the restart after Bob's sweeping tx being confirmed
+ // once the blockbeat starts remembering its last processed block and
+ // can handle looking for spends in the past blocks.
require.NoError(ht, restartAlice())
+ // Alice should have a pending force close channel.
+ ht.AssertNumPendingForceClose(alice, 1)
+
+ // Mine a block to trigger the sweep. This is needed because the
+ // preimage extraction logic from the link is not managed by the
+ // blockbeat, which means the preimage may be sent to the contest
+ // resolver after it's launched.
+ //
+ // TODO(yy): Expose blockbeat to the link layer.
+ ht.MineEmptyBlocks(1)
+
+ // Mine Bob's sweeping tx.
+ ht.MineBlocksAndAssertNumTxes(1, 1)
+
// Since Alice is restarted, we need to track the payments again.
payStream := alice.RPC.TrackPaymentV2(payHash[:])
dustPayStream := alice.RPC.TrackPaymentV2(dustPayHash[:])
@@ -325,9 +335,6 @@ func runTestPaymentHTLCTimeout(ht *lntest.HarnessTest, restartAlice bool) {
// sweep her outgoing HTLC in next block.
ht.MineBlocksAndAssertNumTxes(1, 1)
- // Cleanup the channel.
- ht.CleanupForceClose(alice)
-
// We expect the non-dust payment to marked as failed in Alice's
// database and also from her stream.
ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_FAILED)
diff --git a/itest/lnd_psbt_test.go b/itest/lnd_psbt_test.go
index 438661138b8..a774b3aa70d 100644
--- a/itest/lnd_psbt_test.go
+++ b/itest/lnd_psbt_test.go
@@ -1696,9 +1696,6 @@ func testPsbtChanFundingWithUnstableUtxos(ht *lntest.HarnessTest) {
// Make sure Carol sees her to_remote output from the force close tx.
ht.AssertNumPendingSweeps(carol, 1)
- // Mine one block to trigger the sweep transaction.
- ht.MineEmptyBlocks(1)
-
// We wait for the to_remote sweep tx.
ht.AssertNumUTXOsUnconfirmed(carol, 1)
@@ -1821,9 +1818,6 @@ func testPsbtChanFundingWithUnstableUtxos(ht *lntest.HarnessTest) {
// Make sure Carol sees her to_remote output from the force close tx.
ht.AssertNumPendingSweeps(carol, 1)
- // Mine one block to trigger the sweep transaction.
- ht.MineEmptyBlocks(1)
-
// We wait for the to_remote sweep tx of channelPoint2.
utxos := ht.AssertNumUTXOsUnconfirmed(carol, 1)
diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go
index fd378a25f73..85690da7d3d 100644
--- a/itest/lnd_route_blinding_test.go
+++ b/itest/lnd_route_blinding_test.go
@@ -832,7 +832,6 @@ func testErrorHandlingOnChainFailure(ht *lntest.HarnessTest) {
// we've already mined 1 block so we need one less than our CSV.
ht.MineBlocks(node.DefaultCSV - 1)
ht.AssertNumPendingSweeps(ht.Bob, 1)
- ht.MineEmptyBlocks(1)
ht.MineBlocksAndAssertNumTxes(1, 1)
// Restart bob so that we can test that he's able to recover everything
@@ -852,6 +851,7 @@ func testErrorHandlingOnChainFailure(ht *lntest.HarnessTest) {
ht.AssertNumPendingSweeps(ht.Bob, 0)
ht.MineBlocksAndAssertNumTxes(1, 1)
+ // Assert that the HTLC has cleared.
ht.AssertHTLCNotActive(ht.Bob, testCase.channels[0], hash[:])
ht.AssertHTLCNotActive(ht.Alice, testCase.channels[0], hash[:])
@@ -866,8 +866,9 @@ func testErrorHandlingOnChainFailure(ht *lntest.HarnessTest) {
)
// Clean up the rest of our force close: mine blocks so that Bob's CSV
- // expires plus one block to trigger his sweep and then mine it.
- ht.MineBlocks(node.DefaultCSV + 1)
+ // expires to trigger his sweep and then mine it.
+ ht.MineBlocks(node.DefaultCSV)
+ ht.AssertNumPendingSweeps(ht.Bob, 1)
ht.MineBlocksAndAssertNumTxes(1, 1)
// Bring carol back up so that we can close out the rest of our
diff --git a/itest/lnd_sweep_test.go b/itest/lnd_sweep_test.go
index 158e8768f94..130b8202d0d 100644
--- a/itest/lnd_sweep_test.go
+++ b/itest/lnd_sweep_test.go
@@ -2,7 +2,6 @@ package itest
import (
"fmt"
- "math"
"time"
"github.com/btcsuite/btcd/btcutil"
@@ -61,10 +60,7 @@ func testSweepCPFPAnchorOutgoingTimeout(ht *lntest.HarnessTest) {
// Set up the fee estimator to return the testing fee rate when the
// conf target is the deadline.
- //
- // TODO(yy): switch to conf when `blockbeat` is in place.
- // ht.SetFeeEstimateWithConf(startFeeRateAnchor, deadlineDeltaAnchor)
- ht.SetFeeEstimate(startFeeRateAnchor)
+ ht.SetFeeEstimateWithConf(startFeeRateAnchor, deadlineDeltaAnchor)
// htlcValue is the outgoing HTLC's value.
htlcValue := invoiceAmt
@@ -171,16 +167,20 @@ func testSweepCPFPAnchorOutgoingTimeout(ht *lntest.HarnessTest) {
))
ht.MineEmptyBlocks(int(numBlocks))
- // Assert Bob's force closing tx has been broadcast.
- closeTxid := ht.AssertNumTxsInMempool(1)[0]
+ // Assert Bob's force closing tx has been broadcast. We should see two
+ // txns in the mempool:
+ // 1. Bob's force closing tx.
+ // 2. Bob's anchor sweeping tx CPFPing the force close tx.
+ _, sweepTx := ht.AssertForceCloseAndAnchorTxnsInMempool()
// Remember the force close height so we can calculate the deadline
// height.
forceCloseHeight := ht.CurrentHeight()
- // Bob should have two pending sweeps,
+ var anchorSweep *walletrpc.PendingSweep
+
+ // Bob should have one pending sweep,
// - anchor sweeping from his local commitment.
- // - anchor sweeping from his remote commitment (invalid).
//
// TODO(yy): consider only sweeping the anchor from the local
// commitment. Previously we would sweep up to three versions of
@@ -189,34 +189,22 @@ func testSweepCPFPAnchorOutgoingTimeout(ht *lntest.HarnessTest) {
// their commitment tx and replaces ours. With the new fee bumping, we
// should be safe to only sweep our local anchor since we RBF it on
// every new block, which destroys the remote's ability to pin us.
- sweeps := ht.AssertNumPendingSweeps(bob, 2)
-
- // The two anchor sweeping should have the same deadline height.
- deadlineHeight := forceCloseHeight + deadlineDeltaAnchor
- require.Equal(ht, deadlineHeight, sweeps[0].DeadlineHeight)
- require.Equal(ht, deadlineHeight, sweeps[1].DeadlineHeight)
-
- // Remember the deadline height for the CPFP anchor.
- anchorDeadline := sweeps[0].DeadlineHeight
+ expectedNumSweeps := 1
- // Mine a block so Bob's force closing tx stays in the mempool, which
- // also triggers the CPFP anchor sweep.
- ht.MineEmptyBlocks(1)
+ // For neutrino backend, Bob would have two anchor sweeps - one from
+ // the local and the other from the remote.
+ if ht.IsNeutrinoBackend() {
+ expectedNumSweeps = 2
+ }
- // Bob should still have two pending sweeps,
- // - anchor sweeping from his local commitment.
- // - anchor sweeping from his remote commitment (invalid).
- ht.AssertNumPendingSweeps(bob, 2)
+ anchorSweep = ht.AssertNumPendingSweeps(bob, expectedNumSweeps)[0]
- // We now check the expected fee and fee rate are used for Bob's anchor
- // sweeping tx.
- //
- // We should see Bob's anchor sweeping tx triggered by the above
- // block, along with his force close tx.
- txns := ht.GetNumTxsFromMempool(2)
+ // The anchor sweeping should have the expected deadline height.
+ deadlineHeight := forceCloseHeight + deadlineDeltaAnchor
+ require.Equal(ht, deadlineHeight, anchorSweep.DeadlineHeight)
- // Find the sweeping tx.
- sweepTx := ht.FindSweepingTxns(txns, 1, closeTxid)[0]
+ // Remember the deadline height for the CPFP anchor.
+ anchorDeadline := anchorSweep.DeadlineHeight
// Get the weight for Bob's anchor sweeping tx.
txWeight := ht.CalculateTxWeight(sweepTx)
@@ -228,11 +216,10 @@ func testSweepCPFPAnchorOutgoingTimeout(ht *lntest.HarnessTest) {
fee := uint64(ht.CalculateTxFee(sweepTx))
feeRate := uint64(ht.CalculateTxFeeRate(sweepTx))
- // feeFuncWidth is the width of the fee function. By the time we got
- // here, we've already mined one block, and the fee function maxes
- // out one block before the deadline, so the width is the original
- // deadline minus 2.
- feeFuncWidth := deadlineDeltaAnchor - 2
+ // feeFuncWidth is the width of the fee function. The fee function
+ // maxes out one block before the deadline, so the width is the
+ // original deadline minus 1.
+ feeFuncWidth := deadlineDeltaAnchor - 1
// Calculate the expected delta increased per block.
feeDelta := (cpfpBudget - startFeeAnchor).MulF64(
@@ -258,20 +245,27 @@ func testSweepCPFPAnchorOutgoingTimeout(ht *lntest.HarnessTest) {
// Bob's fee bumper should increase its fees.
ht.MineEmptyBlocks(1)
- // Bob should still have two pending sweeps,
- // - anchor sweeping from his local commitment.
- // - anchor sweeping from his remote commitment (invalid).
- ht.AssertNumPendingSweeps(bob, 2)
-
- // Make sure Bob's old sweeping tx has been removed from the
- // mempool.
- ht.AssertTxNotInMempool(sweepTx.TxHash())
+ // Bob should still have the anchor sweeping from his local
+ // commitment. His anchor sweeping from his remote commitment
+ // is invalid and should be removed.
+ ht.AssertNumPendingSweeps(bob, expectedNumSweeps)
// We expect to see two txns in the mempool,
// - Bob's force close tx.
// - Bob's anchor sweep tx.
ht.AssertNumTxsInMempool(2)
+ // Make sure Bob's old sweeping tx has been removed from the
+ // mempool.
+ ht.AssertTxNotInMempool(sweepTx.TxHash())
+
+ // Assert the two txns are still in the mempool and grab the
+ // sweeping tx.
+ //
+ // NOTE: must call it again after `AssertTxNotInMempool` to
+ // make sure we get the replaced tx.
+ _, sweepTx = ht.AssertForceCloseAndAnchorTxnsInMempool()
+
// We expect the fees to increase by i*delta.
expectedFee := startFeeAnchor + feeDelta.MulF64(float64(i))
expectedFeeRate := chainfee.NewSatPerKWeight(
@@ -280,11 +274,6 @@ func testSweepCPFPAnchorOutgoingTimeout(ht *lntest.HarnessTest) {
// We should see Bob's anchor sweeping tx being fee bumped
// since it's not confirmed, along with his force close tx.
- txns = ht.GetNumTxsFromMempool(2)
-
- // Find the sweeping tx.
- sweepTx = ht.FindSweepingTxns(txns, 1, closeTxid)[0]
-
// Calculate the fee rate of Bob's new sweeping tx.
feeRate = uint64(ht.CalculateTxFeeRate(sweepTx))
@@ -292,9 +281,9 @@ func testSweepCPFPAnchorOutgoingTimeout(ht *lntest.HarnessTest) {
fee = uint64(ht.CalculateTxFee(sweepTx))
ht.Logf("Bob(position=%v): txWeight=%v, expected: [fee=%d, "+
- "feerate=%v], got: [fee=%v, feerate=%v]",
+ "feerate=%v], got: [fee=%v, feerate=%v] in tx %v",
feeFuncWidth-i, txWeight, expectedFee,
- expectedFeeRate, fee, feeRate)
+ expectedFeeRate, fee, feeRate, sweepTx.TxHash())
// Assert Bob's tx has the expected fee and fee rate.
require.InEpsilonf(ht, uint64(expectedFee), fee, 0.01,
@@ -314,22 +303,23 @@ func testSweepCPFPAnchorOutgoingTimeout(ht *lntest.HarnessTest) {
// Mine one more block, we'd use up all the CPFP budget.
ht.MineEmptyBlocks(1)
+ // We expect to see two txns in the mempool,
+ // - Bob's force close tx.
+ // - Bob's anchor sweep tx.
+ ht.AssertNumTxsInMempool(2)
+
// Make sure Bob's old sweeping tx has been removed from the mempool.
ht.AssertTxNotInMempool(sweepTx.TxHash())
// Get the last sweeping tx - we should see two txns here, Bob's anchor
// sweeping tx and his force close tx.
- txns = ht.GetNumTxsFromMempool(2)
-
- // Find the sweeping tx.
- sweepTx = ht.FindSweepingTxns(txns, 1, closeTxid)[0]
+ //
+ // NOTE: must call it again after `AssertTxNotInMempool` to make sure
+ // we get the replaced tx.
+ _, sweepTx = ht.AssertForceCloseAndAnchorTxnsInMempool()
- // Calculate the fee of Bob's new sweeping tx.
- fee = uint64(ht.CalculateTxFee(sweepTx))
-
- // Assert the budget is now used up.
- require.InEpsilonf(ht, uint64(cpfpBudget), fee, 0.01, "want %d, got %d",
- cpfpBudget, fee)
+ // Bob should have the anchor sweeping from his local commitment.
+ ht.AssertNumPendingSweeps(bob, expectedNumSweeps)
// Mine one more block. Since Bob's budget has been used up, there
// won't be any more sweeping attempts. We now assert this by checking
@@ -340,10 +330,7 @@ func testSweepCPFPAnchorOutgoingTimeout(ht *lntest.HarnessTest) {
//
// We expect two txns here, one for the anchor sweeping, the other for
// the force close tx.
- txns = ht.GetNumTxsFromMempool(2)
-
- // Find the sweeping tx.
- currentSweepTx := ht.FindSweepingTxns(txns, 1, closeTxid)[0]
+ _, currentSweepTx := ht.AssertForceCloseAndAnchorTxnsInMempool()
// Assert the anchor sweep tx stays unchanged.
require.Equal(ht, sweepTx.TxHash(), currentSweepTx.TxHash())
@@ -357,6 +344,7 @@ func testSweepCPFPAnchorOutgoingTimeout(ht *lntest.HarnessTest) {
// the HTLC sweeping behaviors so we just perform a simple check and
// exit the test.
ht.AssertNumPendingSweeps(bob, 1)
+ ht.MineBlocksAndAssertNumTxes(1, 1)
// Finally, clean the mempool for the next test.
ht.CleanShutDown()
@@ -404,10 +392,7 @@ func testSweepCPFPAnchorIncomingTimeout(ht *lntest.HarnessTest) {
// Set up the fee estimator to return the testing fee rate when the
// conf target is the deadline.
- //
- // TODO(yy): switch to conf when `blockbeat` is in place.
- // ht.SetFeeEstimateWithConf(startFeeRateAnchor, deadlineDeltaAnchor)
- ht.SetFeeEstimate(startFeeRateAnchor)
+ ht.SetFeeEstimateWithConf(startFeeRateAnchor, deadlineDeltaAnchor)
// Create a preimage, that will be held by Carol.
var preimage lntypes.Preimage
@@ -524,40 +509,30 @@ func testSweepCPFPAnchorIncomingTimeout(ht *lntest.HarnessTest) {
numBlocks := forceCloseHeight - currentHeight
ht.MineEmptyBlocks(int(numBlocks))
- // Assert Bob's force closing tx has been broadcast.
- closeTxid := ht.AssertNumTxsInMempool(1)[0]
+ // Assert Bob's force closing tx has been broadcast. We should see two
+ // txns in the mempool:
+ // 1. Bob's force closing tx.
+ // 2. Bob's anchor sweeping tx CPFPing the force close tx.
+ _, sweepTx := ht.AssertForceCloseAndAnchorTxnsInMempool()
- // Bob should have two pending sweeps,
+ // Bob should have one pending sweep,
// - anchor sweeping from his local commitment.
- // - anchor sweeping from his remote commitment (invalid).
- sweeps := ht.AssertNumPendingSweeps(bob, 2)
-
- // The two anchor sweeping should have the same deadline height.
- deadlineHeight := forceCloseHeight + deadlineDeltaAnchor
- require.Equal(ht, deadlineHeight, sweeps[0].DeadlineHeight)
- require.Equal(ht, deadlineHeight, sweeps[1].DeadlineHeight)
+ expectedNumSweeps := 1
- // Remember the deadline height for the CPFP anchor.
- anchorDeadline := sweeps[0].DeadlineHeight
+ // For neutrino backend, Bob would have two anchor sweeps - one from
+ // the local and the other from the remote.
+ if ht.IsNeutrinoBackend() {
+ expectedNumSweeps = 2
+ }
- // Mine a block so Bob's force closing tx stays in the mempool, which
- // also triggers the CPFP anchor sweep.
- ht.MineEmptyBlocks(1)
+ anchorSweep := ht.AssertNumPendingSweeps(bob, expectedNumSweeps)[0]
- // Bob should still have two pending sweeps,
- // - anchor sweeping from his local commitment.
- // - anchor sweeping from his remote commitment (invalid).
- ht.AssertNumPendingSweeps(bob, 2)
-
- // We now check the expected fee and fee rate are used for Bob's anchor
- // sweeping tx.
- //
- // We should see Bob's anchor sweeping tx triggered by the above
- // block, along with his force close tx.
- txns := ht.GetNumTxsFromMempool(2)
+ // The anchor sweeping should have the expected deadline height.
+ deadlineHeight := forceCloseHeight + deadlineDeltaAnchor
+ require.Equal(ht, deadlineHeight, anchorSweep.DeadlineHeight)
- // Find the sweeping tx.
- sweepTx := ht.FindSweepingTxns(txns, 1, closeTxid)[0]
+ // Remember the deadline height for the CPFP anchor.
+ anchorDeadline := anchorSweep.DeadlineHeight
// Get the weight for Bob's anchor sweeping tx.
txWeight := ht.CalculateTxWeight(sweepTx)
@@ -569,11 +544,10 @@ func testSweepCPFPAnchorIncomingTimeout(ht *lntest.HarnessTest) {
fee := uint64(ht.CalculateTxFee(sweepTx))
feeRate := uint64(ht.CalculateTxFeeRate(sweepTx))
- // feeFuncWidth is the width of the fee function. By the time we got
- // here, we've already mined one block, and the fee function maxes
- // out one block before the deadline, so the width is the original
- // deadline minus 2.
- feeFuncWidth := deadlineDeltaAnchor - 2
+ // feeFuncWidth is the width of the fee function. The fee function
+ // maxes out one block before the deadline, so the width is the
+ // original deadline minus 1.
+ feeFuncWidth := deadlineDeltaAnchor - 1
// Calculate the expected delta increased per block.
feeDelta := (cpfpBudget - startFeeAnchor).MulF64(
@@ -599,10 +573,15 @@ func testSweepCPFPAnchorIncomingTimeout(ht *lntest.HarnessTest) {
// Bob's fee bumper should increase its fees.
ht.MineEmptyBlocks(1)
- // Bob should still have two pending sweeps,
- // - anchor sweeping from his local commitment.
- // - anchor sweeping from his remote commitment (invalid).
- ht.AssertNumPendingSweeps(bob, 2)
+ // Bob should still have the anchor sweeping from his local
+ // commitment. His anchor sweeping from his remote commitment
+ // is invalid and should be removed.
+ ht.AssertNumPendingSweeps(bob, expectedNumSweeps)
+
+ // We expect to see two txns in the mempool,
+ // - Bob's force close tx.
+ // - Bob's anchor sweep tx.
+ ht.AssertNumTxsInMempool(2)
// Make sure Bob's old sweeping tx has been removed from the
// mempool.
@@ -611,7 +590,7 @@ func testSweepCPFPAnchorIncomingTimeout(ht *lntest.HarnessTest) {
// We expect to see two txns in the mempool,
// - Bob's force close tx.
// - Bob's anchor sweep tx.
- ht.AssertNumTxsInMempool(2)
+ _, sweepTx = ht.AssertForceCloseAndAnchorTxnsInMempool()
// We expect the fees to increase by i*delta.
expectedFee := startFeeAnchor + feeDelta.MulF64(float64(i))
@@ -619,13 +598,6 @@ func testSweepCPFPAnchorIncomingTimeout(ht *lntest.HarnessTest) {
expectedFee, txWeight,
)
- // We should see Bob's anchor sweeping tx being fee bumped
- // since it's not confirmed, along with his force close tx.
- txns = ht.GetNumTxsFromMempool(2)
-
- // Find the sweeping tx.
- sweepTx = ht.FindSweepingTxns(txns, 1, closeTxid)[0]
-
// Calculate the fee rate of Bob's new sweeping tx.
feeRate = uint64(ht.CalculateTxFeeRate(sweepTx))
@@ -633,9 +605,9 @@ func testSweepCPFPAnchorIncomingTimeout(ht *lntest.HarnessTest) {
fee = uint64(ht.CalculateTxFee(sweepTx))
ht.Logf("Bob(position=%v): txWeight=%v, expected: [fee=%d, "+
- "feerate=%v], got: [fee=%v, feerate=%v]",
+ "feerate=%v], got: [fee=%v, feerate=%v] in tx %v",
feeFuncWidth-i, txWeight, expectedFee,
- expectedFeeRate, fee, feeRate)
+ expectedFeeRate, fee, feeRate, sweepTx.TxHash())
// Assert Bob's tx has the expected fee and fee rate.
require.InEpsilonf(ht, uint64(expectedFee), fee, 0.01,
@@ -655,15 +627,17 @@ func testSweepCPFPAnchorIncomingTimeout(ht *lntest.HarnessTest) {
// Mine one more block, we'd use up all the CPFP budget.
ht.MineEmptyBlocks(1)
+ // We expect to see two txns in the mempool,
+ // - Bob's force close tx.
+ // - Bob's anchor sweep tx.
+ ht.AssertNumTxsInMempool(2)
+
// Make sure Bob's old sweeping tx has been removed from the mempool.
ht.AssertTxNotInMempool(sweepTx.TxHash())
// Get the last sweeping tx - we should see two txns here, Bob's anchor
// sweeping tx and his force close tx.
- txns = ht.GetNumTxsFromMempool(2)
-
- // Find the sweeping tx.
- sweepTx = ht.FindSweepingTxns(txns, 1, closeTxid)[0]
+ _, sweepTx = ht.AssertForceCloseAndAnchorTxnsInMempool()
// Calculate the fee of Bob's new sweeping tx.
fee = uint64(ht.CalculateTxFee(sweepTx))
@@ -681,10 +655,7 @@ func testSweepCPFPAnchorIncomingTimeout(ht *lntest.HarnessTest) {
//
// We expect two txns here, one for the anchor sweeping, the other for
// the force close tx.
- txns = ht.GetNumTxsFromMempool(2)
-
- // Find the sweeping tx.
- currentSweepTx := ht.FindSweepingTxns(txns, 1, closeTxid)[0]
+ _, currentSweepTx := ht.AssertForceCloseAndAnchorTxnsInMempool()
// Assert the anchor sweep tx stays unchanged.
require.Equal(ht, sweepTx.TxHash(), currentSweepTx.TxHash())
@@ -698,6 +669,7 @@ func testSweepCPFPAnchorIncomingTimeout(ht *lntest.HarnessTest) {
// the HTLC sweeping behaviors so we just perform a simple check and
// exit the test.
ht.AssertNumPendingSweeps(bob, 1)
+ ht.MineBlocksAndAssertNumTxes(1, 1)
// Finally, clean the mempool for the next test.
ht.CleanShutDown()
@@ -735,9 +707,9 @@ func testSweepHTLCs(ht *lntest.HarnessTest) {
cltvDelta := routing.MinCLTVDelta
// Start tracking the deadline delta of Bob's HTLCs. We need one block
- // for the CSV lock, and another block to trigger the sweeper to sweep.
- outgoingHTLCDeadline := int32(cltvDelta - 2)
- incomingHTLCDeadline := int32(lncfg.DefaultIncomingBroadcastDelta - 2)
+ // to trigger the sweeper to sweep.
+ outgoingHTLCDeadline := int32(cltvDelta - 1)
+ incomingHTLCDeadline := int32(lncfg.DefaultIncomingBroadcastDelta - 1)
// startFeeRate1 and startFeeRate2 are returned by the fee estimator in
// sat/kw. They will be used as the starting fee rate for the linear
@@ -894,34 +866,35 @@ func testSweepHTLCs(ht *lntest.HarnessTest) {
// Bob should now have two pending sweeps, one for the anchor on the
// local commitment, the other on the remote commitment.
- ht.AssertNumPendingSweeps(bob, 2)
+ expectedNumSweeps := 1
- // Assert Bob's force closing tx has been broadcast.
- ht.AssertNumTxsInMempool(1)
+ // For neutrino backend, we expect the anchor output from his remote
+ // commitment to be present.
+ if ht.IsNeutrinoBackend() {
+ expectedNumSweeps = 2
+ }
+
+ ht.AssertNumPendingSweeps(bob, expectedNumSweeps)
- // Mine the force close tx, which triggers Bob's contractcourt to offer
- // his outgoing HTLC to his sweeper.
+ // We expect to see two txns in the mempool:
+ // 1. Bob's force closing tx.
+ // 2. Bob's anchor CPFP sweeping tx.
+ ht.AssertNumTxsInMempool(2)
+
+ // Mine the force close tx and CPFP sweeping tx, which triggers Bob's
+ // contractcourt to offer his outgoing HTLC to his sweeper.
//
// NOTE: HTLC outputs are only offered to sweeper when the force close
// tx is confirmed and the CSV has reached.
- ht.MineBlocksAndAssertNumTxes(1, 1)
-
- // Update the blocks left till Bob force closes Alice->Bob.
- blocksTillIncomingSweep--
-
- // Bob should have two pending sweeps, one for the anchor sweeping, the
- // other for the outgoing HTLC.
- ht.AssertNumPendingSweeps(bob, 2)
-
- // Mine one block to confirm Bob's anchor sweeping tx, which will
- // trigger his sweeper to publish the HTLC sweeping tx.
- ht.MineBlocksAndAssertNumTxes(1, 1)
+ ht.MineBlocksAndAssertNumTxes(1, 2)
// Update the blocks left till Bob force closes Alice->Bob.
blocksTillIncomingSweep--
- // Bob should now have one sweep and one sweeping tx in the mempool.
+ // Bob should have one pending sweep for the outgoing HTLC.
ht.AssertNumPendingSweeps(bob, 1)
+
+ // Bob should have one sweeping tx in the mempool.
outgoingSweep := ht.GetNumTxsFromMempool(1)[0]
// Check the shape of the sweeping tx - we expect it to be
@@ -945,8 +918,8 @@ func testSweepHTLCs(ht *lntest.HarnessTest) {
// Assert the initial sweeping tx is using the start fee rate.
outgoingStartFeeRate := ht.CalculateTxFeeRate(outgoingSweep)
require.InEpsilonf(ht, uint64(startFeeRate1),
- uint64(outgoingStartFeeRate), 0.01, "want %d, got %d",
- startFeeRate1, outgoingStartFeeRate)
+ uint64(outgoingStartFeeRate), 0.01, "want %d, got %d in tx=%v",
+ startFeeRate1, outgoingStartFeeRate, outgoingSweep.TxHash())
// Now the start fee rate is checked, we can calculate the fee rate
// delta.
@@ -971,13 +944,12 @@ func testSweepHTLCs(ht *lntest.HarnessTest) {
)
ht.Logf("Bob's %s HTLC (deadline=%v): txWeight=%v, want "+
- "feerate=%v, got feerate=%v, delta=%v", desc,
+ "feerate=%v, got feerate=%v, delta=%v in tx %v", desc,
deadline-position, txSize, expectedFeeRate,
- feeRate, delta)
+ feeRate, delta, sweepTx.TxHash())
require.InEpsilonf(ht, uint64(expectedFeeRate), uint64(feeRate),
- 0.01, "want %v, got %v in tx=%v", expectedFeeRate,
- feeRate, sweepTx.TxHash())
+ 0.01, "want %v, got %v", expectedFeeRate, feeRate)
}
// We now mine enough blocks to trigger Bob to force close channel
@@ -1019,22 +991,34 @@ func testSweepHTLCs(ht *lntest.HarnessTest) {
// Update Bob's fee function position.
outgoingFuncPosition++
- // Bob should now have three pending sweeps:
+ // Bob should now have two pending sweeps:
// 1. the outgoing HTLC output.
// 2. the anchor output from his local commitment.
- // 3. the anchor output from his remote commitment.
- ht.AssertNumPendingSweeps(bob, 3)
+ expectedNumSweeps = 2
- // We should see two txns in the mempool:
+ // For neutrino backend, we expect the anchor output from his remote
+ // commitment to be present.
+ if ht.IsNeutrinoBackend() {
+ expectedNumSweeps = 3
+ }
+
+ ht.AssertNumPendingSweeps(bob, expectedNumSweeps)
+
+ // We should see three txns in the mempool:
// 1. Bob's outgoing HTLC sweeping tx.
// 2. Bob's force close tx for Alice->Bob.
- txns := ht.GetNumTxsFromMempool(2)
+ // 3. Bob's anchor CPFP sweeping tx for Alice->Bob.
+ txns := ht.GetNumTxsFromMempool(3)
// Find the force close tx - we expect it to have a single input.
closeTx := txns[0]
if len(closeTx.TxIn) != 1 {
closeTx = txns[1]
}
+ if len(closeTx.TxIn) != 1 {
+ closeTx = txns[2]
+ }
+ require.Len(ht, closeTx.TxIn, 1)
// We don't care the behavior of the anchor sweep in this test, so we
// mine the force close tx to trigger Bob's contractcourt to offer his
@@ -1050,13 +1034,6 @@ func testSweepHTLCs(ht *lntest.HarnessTest) {
// 3. the anchor sweeping on Alice-> Bob.
ht.AssertNumPendingSweeps(bob, 3)
- // Mine one block, which will trigger his sweeper to publish his
- // incoming HTLC sweeping tx.
- ht.MineEmptyBlocks(1)
-
- // Update the fee function's positions.
- outgoingFuncPosition++
-
// We should see three txns in the mempool:
// 1. the outgoing HTLC sweeping tx.
// 2. the incoming HTLC sweeping tx.
@@ -1224,8 +1201,9 @@ func testSweepHTLCs(ht *lntest.HarnessTest) {
// Test:
// 1. Alice's anchor sweeping is not attempted, instead, it should be swept
// together with her to_local output using the no deadline path.
-// 2. Bob would also sweep his anchor and to_local outputs in a single
-// sweeping tx using the no deadline path.
+// 2. Bob would also sweep his anchor and to_local outputs separately due to
+// they have different deadline heights, which means only the to_local
+// sweeping tx will succeed as the anchor sweeping is not economical.
// 3. Both Alice and Bob's RBF attempts are using the fee rates calculated
// from the deadline and budget.
// 4. Wallet UTXOs requirements are met - neither Alice nor Bob needs wallet
@@ -1238,10 +1216,20 @@ func testSweepCommitOutputAndAnchor(ht *lntest.HarnessTest) {
// config.
deadline := uint32(1000)
- // The actual deadline used by the fee function will be one block off
- // from the deadline configured as we require one block to be mined to
- // trigger the sweep.
- deadlineA, deadlineB := deadline-1, deadline-1
+ // deadlineA is the deadline used for Alice, since her commit output is
+ // offered to the sweeper at CSV-1. With a deadline of 1000, her actual
+ // width of her fee func is CSV+1000-1. Given we are using a CSV of 2
+ // here, her fee func deadline then becomes 1001.
+ deadlineA := deadline + 1
+
+ // deadlineB is the deadline used for Bob, the actual deadline used by
+ // the fee function will be one block off from the deadline configured
+ // as we require one block to be mined to trigger the sweep. In
+ // addition, when sweeping his to_local output from Alice's commit tx,
+ // because of CSV of 2, the starting height will be
+ // "force_close_height+2", which means when the sweep request is
+ // received by the sweeper, the actual deadline delta is "deadline+1".
+ deadlineB := deadline + 1
// startFeeRate is returned by the fee estimator in sat/kw. This
// will be used as the starting fee rate for the linear fee func used
@@ -1252,7 +1240,7 @@ func testSweepCommitOutputAndAnchor(ht *lntest.HarnessTest) {
// Set up the fee estimator to return the testing fee rate when the
// conf target is the deadline.
- ht.SetFeeEstimateWithConf(startFeeRate, deadlineA)
+ ht.SetFeeEstimateWithConf(startFeeRate, deadlineB)
// toLocalCSV is the CSV delay for Alice's to_local output. We use a
// small value to save us from mining blocks.
@@ -1260,25 +1248,7 @@ func testSweepCommitOutputAndAnchor(ht *lntest.HarnessTest) {
// NOTE: once the force close tx is confirmed, we expect anchor
// sweeping starts. Then two more block later the commit output
// sweeping starts.
- //
- // NOTE: The CSV value is chosen to be 3 instead of 2, to reduce the
- // possibility of flakes as there is a race between the two goroutines:
- // G1 - Alice's sweeper receives the commit output.
- // G2 - Alice's sweeper receives the new block mined.
- // G1 is triggered by the same block being received by Alice's
- // contractcourt, deciding the commit output is mature and offering it
- // to her sweeper. Normally, we'd expect G2 to be finished before G1
- // because it's the same block processed by both contractcourt and
- // sweeper. However, if G2 is delayed (maybe the sweeper is slow in
- // finishing its previous round), G1 may finish before G2. This will
- // cause the sweeper to add the commit output to its pending inputs,
- // and once G2 fires, it will then start sweeping this output,
- // resulting a valid sweep tx being created using her commit and anchor
- // outputs.
- //
- // TODO(yy): fix the above issue by making sure subsystems share the
- // same view on current block height.
- toLocalCSV := 3
+ toLocalCSV := 2
// htlcAmt is the amount of the HTLC in sats, this should be Alice's
// to_remote amount that goes to Bob.
@@ -1377,155 +1347,41 @@ func testSweepCommitOutputAndAnchor(ht *lntest.HarnessTest) {
// - commit sweeping from the to_remote on Alice's commit tx.
ht.AssertNumPendingSweeps(bob, 2)
+ // Bob's sweeper should have broadcast the commit output sweeping tx.
+ // At the block which mined the force close tx, Bob's `chainWatcher`
+ // will process the blockbeat first, which sends a signal to his
+ // `ChainArbitrator` to launch the resolvers. Once launched, the sweep
+ // requests will be sent to the sweeper. Finally, when the sweeper
+ // receives this blockbeat, it will create the sweeping tx and publish
+ // it.
+ ht.AssertNumTxsInMempool(1)
+
// Mine one more empty block should trigger Bob's sweeping. Since we
- // use a CSV of 3, this means Alice's to_local output is one block away
- // from being mature.
+ // use a CSV of 2, this means Alice's to_local output is now mature.
ht.MineEmptyBlocks(1)
- // We expect to see one sweeping tx in the mempool:
- // - Alice's anchor sweeping tx must have been failed due to the fee
- // rate chosen in this test - the anchor sweep tx has no output.
- // - Bob's sweeping tx, which sweeps both his anchor and commit outputs.
- bobSweepTx := ht.GetNumTxsFromMempool(1)[0]
-
// We expect two pending sweeps for Bob - anchor and commit outputs.
- pendingSweepBob := ht.AssertNumPendingSweeps(bob, 2)[0]
-
- // The sweeper may be one block behind contractcourt, so we double
- // check the actual deadline.
- //
- // TODO(yy): assert they are equal once blocks are synced via
- // `blockbeat`.
- currentHeight := int32(ht.CurrentHeight())
- actualDeadline := int32(pendingSweepBob.DeadlineHeight) - currentHeight
- if actualDeadline != int32(deadlineB) {
- ht.Logf("!!! Found unsynced block between sweeper and "+
- "contractcourt, expected deadline=%v, got=%v",
- deadlineB, actualDeadline)
-
- deadlineB = uint32(actualDeadline)
- }
-
- // Alice should still have one pending sweep - the anchor output.
- ht.AssertNumPendingSweeps(alice, 1)
-
- // We now check Bob's sweeping tx.
- //
- // Bob's sweeping tx should have 2 inputs, one from his commit output,
- // the other from his anchor output.
- require.Len(ht, bobSweepTx.TxIn, 2)
-
- // Because Bob is sweeping without deadline pressure, the starting fee
- // rate should be the min relay fee rate.
- bobStartFeeRate := ht.CalculateTxFeeRate(bobSweepTx)
- require.InEpsilonf(ht, uint64(chainfee.FeePerKwFloor),
- uint64(bobStartFeeRate), 0.01, "want %v, got %v",
- chainfee.FeePerKwFloor, bobStartFeeRate)
-
- // With Bob's starting fee rate being validated, we now calculate his
- // ending fee rate and fee rate delta.
- //
- // Bob sweeps two inputs - anchor and commit, so the starting budget
- // should come from the sum of these two.
- bobValue := btcutil.Amount(bobToLocal + 330)
- bobBudget := bobValue.MulF64(contractcourt.DefaultBudgetRatio)
-
- // Calculate the ending fee rate and fee rate delta used in his fee
- // function.
- bobTxWeight := ht.CalculateTxWeight(bobSweepTx)
- bobEndingFeeRate := chainfee.NewSatPerKWeight(bobBudget, bobTxWeight)
- bobFeeRateDelta := (bobEndingFeeRate - bobStartFeeRate) /
- chainfee.SatPerKWeight(deadlineB-1)
-
- // Mine an empty block, which should trigger Alice's contractcourt to
- // offer her commit output to the sweeper.
- ht.MineEmptyBlocks(1)
-
- // Alice should have both anchor and commit as the pending sweep
- // requests.
- aliceSweeps := ht.AssertNumPendingSweeps(alice, 2)
- aliceAnchor, aliceCommit := aliceSweeps[0], aliceSweeps[1]
- if aliceAnchor.AmountSat > aliceCommit.AmountSat {
- aliceAnchor, aliceCommit = aliceCommit, aliceAnchor
- }
-
- // The sweeper may be one block behind contractcourt, so we double
- // check the actual deadline.
- //
- // TODO(yy): assert they are equal once blocks are synced via
- // `blockbeat`.
- currentHeight = int32(ht.CurrentHeight())
- actualDeadline = int32(aliceCommit.DeadlineHeight) - currentHeight
- if actualDeadline != int32(deadlineA) {
- ht.Logf("!!! Found unsynced block between Alice's sweeper and "+
- "contractcourt, expected deadline=%v, got=%v",
- deadlineA, actualDeadline)
-
- deadlineA = uint32(actualDeadline)
- }
-
- // We now wait for 30 seconds to overcome the flake - there's a block
- // race between contractcourt and sweeper, causing the sweep to be
- // broadcast earlier.
- //
- // TODO(yy): remove this once `blockbeat` is in place.
- aliceStartPosition := 0
- var aliceFirstSweepTx *wire.MsgTx
- err := wait.NoError(func() error {
- mem := ht.GetRawMempool()
- if len(mem) != 2 {
- return fmt.Errorf("want 2, got %v in mempool: %v",
- len(mem), mem)
- }
-
- // If there are two txns, it means Alice's sweep tx has been
- // created and published.
- aliceStartPosition = 1
-
- txns := ht.GetNumTxsFromMempool(2)
- aliceFirstSweepTx = txns[0]
-
- // Reassign if the second tx is larger.
- if txns[1].TxOut[0].Value > aliceFirstSweepTx.TxOut[0].Value {
- aliceFirstSweepTx = txns[1]
- }
-
- return nil
- }, wait.DefaultTimeout)
- ht.Logf("Checking mempool got: %v", err)
-
- // Mine an empty block, which should trigger Alice's sweeper to publish
- // her commit sweep along with her anchor output.
- ht.MineEmptyBlocks(1)
+ ht.AssertNumPendingSweeps(bob, 2)
- // If Alice has already published her initial sweep tx, the above mined
- // block would trigger an RBF. We now need to assert the mempool has
- // removed the replaced tx.
- if aliceFirstSweepTx != nil {
- ht.AssertTxNotInMempool(aliceFirstSweepTx.TxHash())
- }
+ // We expect two pending sweeps for Alice - anchor and commit outputs.
+ ht.AssertNumPendingSweeps(alice, 2)
// We also remember the positions of fee functions used by Alice and
// Bob. They will be used to calculate the expected fee rates later.
- //
- // Alice's sweeping tx has just been created, so she is at the starting
- // position. For Bob, due to the above mined blocks, his fee function
- // is now at position 2.
- alicePosition, bobPosition := uint32(aliceStartPosition), uint32(2)
+ alicePosition, bobPosition := uint32(0), uint32(1)
// We should see two txns in the mempool:
// - Alice's sweeping tx, which sweeps her commit output at the
// starting fee rate - Alice's anchor output won't be swept with her
// commit output together because they have different deadlines.
- // - Bob's previous sweeping tx, which sweeps both his anchor and
- // commit outputs, at the starting fee rate.
+ // - Bob's previous sweeping tx, which sweeps his and commit outputs,
+ // at the starting fee rate.
txns := ht.GetNumTxsFromMempool(2)
// Assume the first tx is Alice's sweeping tx, if the second tx has a
// larger output value, then that's Alice's as her to_local value is
// much gearter.
- aliceSweepTx := txns[0]
- bobSweepTx = txns[1]
+ aliceSweepTx, bobSweepTx := txns[0], txns[1]
// Swap them if bobSweepTx is smaller.
if bobSweepTx.TxOut[0].Value > aliceSweepTx.TxOut[0].Value {
@@ -1539,20 +1395,6 @@ func testSweepCommitOutputAndAnchor(ht *lntest.HarnessTest) {
require.Len(ht, aliceSweepTx.TxIn, 1)
require.Len(ht, aliceSweepTx.TxOut, 1)
- // We now check Alice's sweeping tx to see if it's already published.
- //
- // TODO(yy): remove this check once we have better block control.
- aliceSweeps = ht.AssertNumPendingSweeps(alice, 2)
- aliceCommit = aliceSweeps[0]
- if aliceCommit.AmountSat < aliceSweeps[1].AmountSat {
- aliceCommit = aliceSweeps[1]
- }
- if aliceCommit.BroadcastAttempts > 1 {
- ht.Logf("!!! Alice's commit sweep has already been broadcast, "+
- "broadcast_attempts=%v", aliceCommit.BroadcastAttempts)
- alicePosition = aliceCommit.BroadcastAttempts
- }
-
// Alice's sweeping tx should use the min relay fee rate as there's no
// deadline pressure.
aliceStartingFeeRate := chainfee.FeePerKwFloor
@@ -1567,7 +1409,7 @@ func testSweepCommitOutputAndAnchor(ht *lntest.HarnessTest) {
aliceTxWeight := uint64(ht.CalculateTxWeight(aliceSweepTx))
aliceEndingFeeRate := sweep.DefaultMaxFeeRate.FeePerKWeight()
aliceFeeRateDelta := (aliceEndingFeeRate - aliceStartingFeeRate) /
- chainfee.SatPerKWeight(deadlineA-1)
+ chainfee.SatPerKWeight(deadlineA)
aliceFeeRate := ht.CalculateTxFeeRate(aliceSweepTx)
expectedFeeRateAlice := aliceStartingFeeRate +
@@ -1576,119 +1418,41 @@ func testSweepCommitOutputAndAnchor(ht *lntest.HarnessTest) {
uint64(aliceFeeRate), 0.02, "want %v, got %v",
expectedFeeRateAlice, aliceFeeRate)
- // We now check Bob' sweeping tx.
- //
- // The above mined block will trigger Bob's sweeper to RBF his previous
- // sweeping tx, which will fail due to RBF rule#4 - the additional fees
- // paid are not sufficient. This happens as our default incremental
- // relay fee rate is 1 sat/vb, with the tx size of 771 weight units, or
- // 192 vbytes, we need to pay at least 192 sats more to be able to RBF.
- // However, since Bob's budget delta is (100_000 + 330) * 0.5 / 1008 =
- // 49.77 sats, it means Bob can only perform a successful RBF every 4
- // blocks.
- //
- // Assert Bob's sweeping tx is not RBFed.
- bobFeeRate := ht.CalculateTxFeeRate(bobSweepTx)
- expectedFeeRateBob := bobStartFeeRate
- require.InEpsilonf(ht, uint64(expectedFeeRateBob), uint64(bobFeeRate),
- 0.01, "want %d, got %d", expectedFeeRateBob, bobFeeRate)
-
- // reloclateAlicePosition is a temp hack to find the actual fee
- // function position used for Alice. Due to block sync issue among the
- // subsystems, we can end up having this situation:
- // - sweeper is at block 2, starts sweeping an input with deadline 100.
- // - fee bumper is at block 1, and thinks the conf target is 99.
- // - new block 3 arrives, the func now is at position 2.
+ // We now check Bob's sweeping tx.
//
- // TODO(yy): fix it using `blockbeat`.
- reloclateAlicePosition := func() {
- // Mine an empty block to trigger the possible RBF attempts.
- ht.MineEmptyBlocks(1)
-
- // Increase the positions for both fee functions.
- alicePosition++
- bobPosition++
+ // Bob's sweeping tx should have one input, which is his commit output.
+ // His anchor output won't be swept due to it being uneconomical.
+ require.Len(ht, bobSweepTx.TxIn, 1, "tx=%v", bobSweepTx.TxHash())
- // We expect two pending sweeps for both nodes as we are mining
- // empty blocks.
- ht.AssertNumPendingSweeps(alice, 2)
- ht.AssertNumPendingSweeps(bob, 2)
-
- // We expect to see both Alice's and Bob's sweeping txns in the
- // mempool.
- ht.AssertNumTxsInMempool(2)
-
- // Make sure Alice's old sweeping tx has been removed from the
- // mempool.
- ht.AssertTxNotInMempool(aliceSweepTx.TxHash())
-
- // We should see two txns in the mempool:
- // - Alice's sweeping tx, which sweeps both her anchor and
- // commit outputs, using the increased fee rate.
- // - Bob's previous sweeping tx, which sweeps both his anchor
- // and commit outputs, at the possible increased fee rate.
- txns = ht.GetNumTxsFromMempool(2)
-
- // Assume the first tx is Alice's sweeping tx, if the second tx
- // has a larger output value, then that's Alice's as her
- // to_local value is much gearter.
- aliceSweepTx = txns[0]
- bobSweepTx = txns[1]
-
- // Swap them if bobSweepTx is smaller.
- if bobSweepTx.TxOut[0].Value > aliceSweepTx.TxOut[0].Value {
- aliceSweepTx, bobSweepTx = bobSweepTx, aliceSweepTx
- }
-
- // Alice's sweeping tx should be increased.
- aliceFeeRate := ht.CalculateTxFeeRate(aliceSweepTx)
- expectedFeeRate := aliceStartingFeeRate +
- aliceFeeRateDelta*chainfee.SatPerKWeight(alicePosition)
-
- ht.Logf("Alice(deadline=%v): txWeight=%v, want feerate=%v, "+
- "got feerate=%v, delta=%v", deadlineA-alicePosition,
- aliceTxWeight, expectedFeeRate, aliceFeeRate,
- aliceFeeRateDelta)
-
- nextPosition := alicePosition + 1
- nextFeeRate := aliceStartingFeeRate +
- aliceFeeRateDelta*chainfee.SatPerKWeight(nextPosition)
-
- // Calculate the distances.
- delta := math.Abs(float64(aliceFeeRate - expectedFeeRate))
- deltaNext := math.Abs(float64(aliceFeeRate - nextFeeRate))
-
- // Exit early if the first distance is smaller - it means we
- // are at the right fee func position.
- if delta < deltaNext {
- require.InEpsilonf(ht, uint64(expectedFeeRate),
- uint64(aliceFeeRate), 0.02, "want %v, got %v "+
- "in tx=%v", expectedFeeRate,
- aliceFeeRate, aliceSweepTx.TxHash())
-
- return
- }
-
- alicePosition++
- ht.Logf("Jump position for Alice(deadline=%v): txWeight=%v, "+
- "want feerate=%v, got feerate=%v, delta=%v",
- deadlineA-alicePosition, aliceTxWeight, nextFeeRate,
- aliceFeeRate, aliceFeeRateDelta)
+ // Because Bob is sweeping without deadline pressure, the starting fee
+ // rate should be the min relay fee rate.
+ bobStartFeeRate := ht.CalculateTxFeeRate(bobSweepTx)
+ require.InEpsilonf(ht, uint64(chainfee.FeePerKwFloor),
+ uint64(bobStartFeeRate), 0.01, "want %v, got %v",
+ chainfee.FeePerKwFloor, bobStartFeeRate)
- require.InEpsilonf(ht, uint64(nextFeeRate),
- uint64(aliceFeeRate), 0.02, "want %v, got %v in tx=%v",
- nextFeeRate, aliceFeeRate, aliceSweepTx.TxHash())
- }
+ // With Bob's starting fee rate being validated, we now calculate his
+ // ending fee rate and fee rate delta.
+ //
+ // Bob sweeps one input - the commit output.
+ bobValue := btcutil.Amount(bobToLocal)
+ bobBudget := bobValue.MulF64(contractcourt.DefaultBudgetRatio)
- reloclateAlicePosition()
+ // Calculate the ending fee rate and fee rate delta used in his fee
+ // function.
+ bobTxWeight := ht.CalculateTxWeight(bobSweepTx)
+ bobEndingFeeRate := chainfee.NewSatPerKWeight(bobBudget, bobTxWeight)
+ bobFeeRateDelta := (bobEndingFeeRate - bobStartFeeRate) /
+ chainfee.SatPerKWeight(deadlineB-1)
+ expectedFeeRateBob := bobStartFeeRate
- // We now mine 7 empty blocks. For each block mined, we'd see Alice's
+ // We now mine 8 empty blocks. For each block mined, we'd see Alice's
// sweeping tx being RBFed. For Bob, he performs a fee bump every
- // block, but will only publish a tx every 4 blocks mined as some of
+ // block, but will only publish a tx every 3 blocks mined as some of
// the fee bumps is not sufficient to meet the fee requirements
// enforced by RBF. Since his fee function is already at position 1,
// mining 7 more blocks means he will RBF his sweeping tx twice.
- for i := 1; i < 7; i++ {
+ for i := 1; i < 9; i++ {
// Mine an empty block to trigger the possible RBF attempts.
ht.MineEmptyBlocks(1)
@@ -1711,9 +1475,9 @@ func testSweepCommitOutputAndAnchor(ht *lntest.HarnessTest) {
// Make sure Bob's old sweeping tx has been removed from the
// mempool. Since Bob's sweeping tx will only be successfully
- // RBFed every 4 blocks, his old sweeping tx only will be
- // removed when there are 4 blocks increased.
- if bobPosition%4 == 0 {
+ // RBFed every 3 blocks, his old sweeping tx only will be
+ // removed when there are 3 blocks increased.
+ if bobPosition%3 == 0 {
ht.AssertTxNotInMempool(bobSweepTx.TxHash())
}
@@ -1749,9 +1513,10 @@ func testSweepCommitOutputAndAnchor(ht *lntest.HarnessTest) {
aliceFeeRateDelta*chainfee.SatPerKWeight(alicePosition)
ht.Logf("Alice(deadline=%v): txWeight=%v, want feerate=%v, "+
- "got feerate=%v, delta=%v", deadlineA-alicePosition,
- aliceTxWeight, expectedFeeRateAlice, aliceFeeRate,
- aliceFeeRateDelta)
+ "got feerate=%v, delta=%v in tx %v",
+ deadlineA-alicePosition, aliceTxWeight,
+ expectedFeeRateAlice, aliceFeeRate,
+ aliceFeeRateDelta, aliceSweepTx.TxHash())
require.InEpsilonf(ht, uint64(expectedFeeRateAlice),
uint64(aliceFeeRate), 0.02, "want %v, got %v in tx=%v",
@@ -1767,16 +1532,17 @@ func testSweepCommitOutputAndAnchor(ht *lntest.HarnessTest) {
accumulatedDelta := bobFeeRateDelta *
chainfee.SatPerKWeight(bobPosition)
- // Bob's sweeping tx will only be successfully RBFed every 4
+ // Bob's sweeping tx will only be successfully RBFed every 3
// blocks.
- if bobPosition%4 == 0 {
+ if bobPosition%3 == 0 {
expectedFeeRateBob = bobStartFeeRate + accumulatedDelta
}
ht.Logf("Bob(deadline=%v): txWeight=%v, want feerate=%v, "+
- "got feerate=%v, delta=%v", deadlineB-bobPosition,
- bobTxWeight, expectedFeeRateBob, bobFeeRate,
- bobFeeRateDelta)
+ "got feerate=%v, delta=%v in tx %v",
+ deadlineB-bobPosition, bobTxWeight,
+ expectedFeeRateBob, bobFeeRate,
+ bobFeeRateDelta, bobSweepTx.TxHash())
require.InEpsilonf(ht, uint64(expectedFeeRateBob),
uint64(bobFeeRate), 0.02, "want %d, got %d in tx=%v",
@@ -2115,6 +1881,7 @@ func testBumpForceCloseFee(ht *lntest.HarnessTest) {
if ht.IsNeutrinoBackend() {
ht.Skipf("skipping BumpForceCloseFee test for neutrino backend")
}
+
// fundAmt is the funding amount.
fundAmt := btcutil.Amount(1_000_000)
@@ -2140,6 +1907,7 @@ func testBumpForceCloseFee(ht *lntest.HarnessTest) {
// Unwrap the results.
chanPoint := chanPoints[0]
alice := nodes[0]
+ bob := nodes[1]
// We need to fund alice with 2 wallet inputs so that we can test to
// increase the fee rate of the anchor cpfp via two subsequent calls of
@@ -2252,6 +2020,10 @@ func testBumpForceCloseFee(ht *lntest.HarnessTest) {
txns = ht.GetNumTxsFromMempool(2)
ht.FindSweepingTxns(txns, 1, closingTx.TxHash())
+ // Shut down Bob, otherwise he will create a sweeping tx to collect the
+ // to_remote output once Alice's force closing tx is confirmed below.
+ ht.Shutdown(bob)
+
// Mine both transactions, the closing tx and the anchor cpfp tx.
// This is needed to clean up the mempool.
ht.MineBlocksAndAssertNumTxes(1, 2)
diff --git a/itest/lnd_watchtower_test.go b/itest/lnd_watchtower_test.go
index 0a36b077d2d..a68df852bcd 100644
--- a/itest/lnd_watchtower_test.go
+++ b/itest/lnd_watchtower_test.go
@@ -19,22 +19,21 @@ import (
"github.com/stretchr/testify/require"
)
-// testWatchtower tests the behaviour of the watchtower client and server.
-func testWatchtower(ht *lntest.HarnessTest) {
- ht.Run("revocation", func(t *testing.T) {
- tt := ht.Subtest(t)
- testRevokedCloseRetributionAltruistWatchtower(tt)
- })
-
- ht.Run("session deletion", func(t *testing.T) {
- tt := ht.Subtest(t)
- testTowerClientSessionDeletion(tt)
- })
-
- ht.Run("tower and session activation", func(t *testing.T) {
- tt := ht.Subtest(t)
- testTowerClientTowerAndSessionManagement(tt)
- })
+// watchtowerTestCases defines a set of tests to check the behaviour of the
+// watchtower client and server.
+var watchtowerTestCases = []*lntest.TestCase{
+ {
+ Name: "watchtower revoked close retribution altruist",
+ TestFunc: testRevokedCloseRetributionAltruistWatchtower,
+ },
+ {
+ Name: "watchtower client session deletion",
+ TestFunc: testTowerClientSessionDeletion,
+ },
+ {
+ Name: "watchtower client tower and session management",
+ TestFunc: testTowerClientTowerAndSessionManagement,
+ },
}
// testTowerClientTowerAndSessionManagement tests the various control commands
@@ -565,6 +564,15 @@ func testRevokedCloseRetributionAltruistWatchtowerCase(ht *lntest.HarnessTest,
// then been swept to his wallet by Willy.
require.NoError(ht, restart(), "unable to restart dave")
+ // For neutrino backend, we may need to mine one more block to trigger
+ // the chain watcher to act.
+ //
+ // TODO(yy): remove it once the blockbeat remembers the last block
+ // processed.
+ if ht.IsNeutrinoBackend() {
+ ht.MineEmptyBlocks(1)
+ }
+
err = wait.NoError(func() error {
daveBalResp := dave.RPC.ChannelBalance()
if daveBalResp.LocalBalance.Sat != 0 {
@@ -579,16 +587,6 @@ func testRevokedCloseRetributionAltruistWatchtowerCase(ht *lntest.HarnessTest,
ht.AssertNumPendingForceClose(dave, 0)
- // If this is an anchor channel, Dave would offer his sweeper the
- // anchor. However, due to no time-sensitive outputs involved, the
- // anchor sweeping won't happen as it's uneconomical.
- if lntest.CommitTypeHasAnchors(commitType) {
- ht.AssertNumPendingSweeps(dave, 1)
-
- // Mine a block to trigger the sweep.
- ht.MineEmptyBlocks(1)
- }
-
// Check that Dave's wallet balance is increased.
err = wait.NoError(func() error {
daveBalResp := dave.RPC.WalletBalance()
@@ -657,17 +655,12 @@ func generateBackups(ht *lntest.HarnessTest, srcNode,
)
send := func(node *node.HarnessNode, payReq string) {
- stream := node.RPC.SendPayment(
- &routerrpc.SendPaymentRequest{
- PaymentRequest: payReq,
- TimeoutSeconds: 60,
- FeeLimitMsat: noFeeLimitMsat,
- },
- )
-
- ht.AssertPaymentStatusFromStream(
- stream, lnrpc.Payment_SUCCEEDED,
- )
+ req := &routerrpc.SendPaymentRequest{
+ PaymentRequest: payReq,
+ TimeoutSeconds: 60,
+ FeeLimitMsat: noFeeLimitMsat,
+ }
+ ht.SendPaymentAssertSettled(node, req)
}
// Pay each invoice.
diff --git a/itest/lnd_wipe_fwdpkgs_test.go b/itest/lnd_wipe_fwdpkgs_test.go
index a302f596a63..b6211d7f8e5 100644
--- a/itest/lnd_wipe_fwdpkgs_test.go
+++ b/itest/lnd_wipe_fwdpkgs_test.go
@@ -117,9 +117,6 @@ func testWipeForwardingPackages(ht *lntest.HarnessTest) {
// Alice should one pending sweep.
ht.AssertNumPendingSweeps(alice, 1)
- // Mine a block to trigger the sweep.
- ht.MineBlocks(1)
-
// Mine 1 block to get Alice's sweeping tx confirmed.
ht.MineBlocksAndAssertNumTxes(1, 1)
diff --git a/lntest/harness.go b/lntest/harness.go
index 8e8fcd39362..606b64351be 100644
--- a/lntest/harness.go
+++ b/lntest/harness.go
@@ -8,16 +8,19 @@ import (
"time"
"github.com/btcsuite/btcd/blockchain"
+ "github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/go-errors/errors"
"github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/kvdb/etcd"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
+ "github.com/lightningnetwork/lnd/lnrpc/signrpc"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntest/miner"
"github.com/lightningnetwork/lnd/lntest/node"
@@ -26,6 +29,7 @@ import (
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
+ "github.com/lightningnetwork/lnd/routing"
"github.com/stretchr/testify/require"
)
@@ -49,6 +53,9 @@ const (
// maxBlocksAllowed specifies the max allowed value to be used when
// mining blocks.
maxBlocksAllowed = 100
+
+ finalCltvDelta = routing.MinCLTVDelta // 18.
+ thawHeightDelta = finalCltvDelta * 2 // 36.
)
// TestCase defines a test case that's been used in the integration test.
@@ -1653,6 +1660,22 @@ func (h *HarnessTest) CleanupForceClose(hn *node.HarnessNode) {
// Wait for the channel to be marked pending force close.
h.AssertNumPendingForceClose(hn, 1)
+ // Mine enough blocks for the node to sweep its funds from the force
+ // closed channel. The commit sweep resolver is offers the input to the
+ // sweeper when it's force closed, and broadcast the sweep tx at
+ // defaulCSV-1.
+ //
+ // NOTE: we might empty blocks here as we don't know the exact number
+ // of blocks to mine. This may end up mining more blocks than needed.
+ h.MineEmptyBlocks(node.DefaultCSV - 1)
+
+ // Assert there is one pending sweep.
+ h.AssertNumPendingSweeps(hn, 1)
+
+ // The node should now sweep the funds, clean up by mining the sweeping
+ // tx.
+ h.MineBlocksAndAssertNumTxes(1, 1)
+
// Mine blocks to get any second level HTLC resolved. If there are no
// HTLCs, this will behave like h.AssertNumPendingCloseChannels.
h.mineTillForceCloseResolved(hn)
@@ -1994,7 +2017,8 @@ func (h *HarnessTest) AssertSweepFound(hn *node.HarnessNode,
return nil
}
- return fmt.Errorf("sweep tx %v not found", sweep)
+ return fmt.Errorf("sweep tx %v not found in resp %v", sweep,
+ sweepResp)
}, wait.DefaultTimeout)
require.NoError(h, err, "%s: timeout checking sweep tx", hn.Name())
}
@@ -2308,12 +2332,40 @@ func (h *HarnessTest) openChannelsForNodes(nodes []*node.HarnessNode,
// Sanity check the params.
require.Greater(h, len(nodes), 1, "need at least 2 nodes")
+ // attachFundingShim is a helper closure that optionally attaches a
+ // funding shim to the open channel params and returns it.
+ attachFundingShim := func(
+ nodeA, nodeB *node.HarnessNode) OpenChannelParams {
+
+ // If this channel is not a script enforced lease channel,
+ // we'll do nothing and return the params.
+ leasedType := lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE
+ if p.CommitmentType != leasedType {
+ return p
+ }
+
+ // Otherwise derive the funding shim, attach it to the original
+ // open channel params and return it.
+ minerHeight := h.CurrentHeight()
+ thawHeight := minerHeight + thawHeightDelta
+ fundingShim, _ := h.DeriveFundingShim(
+ nodeA, nodeB, p.Amt, thawHeight, true, leasedType,
+ )
+
+ p.FundingShim = fundingShim
+
+ return p
+ }
+
// Open channels in batch to save blocks mined.
reqs := make([]*OpenChannelRequest, 0, len(nodes)-1)
for i := 0; i < len(nodes)-1; i++ {
nodeA := nodes[i]
nodeB := nodes[i+1]
+ // Optionally attach a funding shim to the open channel params.
+ p = attachFundingShim(nodeA, nodeB)
+
req := &OpenChannelRequest{
Local: nodeA,
Remote: nodeB,
@@ -2364,3 +2416,122 @@ func (h *HarnessTest) openZeroConfChannelsForNodes(nodes []*node.HarnessNode,
return resp
}
+
+// DeriveFundingShim creates a channel funding shim by deriving the necessary
+// keys on both sides.
+func (h *HarnessTest) DeriveFundingShim(alice, bob *node.HarnessNode,
+ chanSize btcutil.Amount, thawHeight uint32, publish bool,
+ commitType lnrpc.CommitmentType) (*lnrpc.FundingShim,
+ *lnrpc.ChannelPoint) {
+
+ keyLoc := &signrpc.KeyLocator{KeyFamily: 9999}
+ carolFundingKey := alice.RPC.DeriveKey(keyLoc)
+ daveFundingKey := bob.RPC.DeriveKey(keyLoc)
+
+ // Now that we have the multi-sig keys for each party, we can manually
+ // construct the funding transaction. We'll instruct the backend to
+ // immediately create and broadcast a transaction paying out an exact
+ // amount. Normally this would reside in the mempool, but we just
+ // confirm it now for simplicity.
+ var (
+ fundingOutput *wire.TxOut
+ musig2 bool
+ err error
+ )
+
+ if commitType == lnrpc.CommitmentType_SIMPLE_TAPROOT ||
+ commitType == lnrpc.CommitmentType_SIMPLE_TAPROOT_OVERLAY {
+
+ var carolKey, daveKey *btcec.PublicKey
+ carolKey, err = btcec.ParsePubKey(carolFundingKey.RawKeyBytes)
+ require.NoError(h, err)
+ daveKey, err = btcec.ParsePubKey(daveFundingKey.RawKeyBytes)
+ require.NoError(h, err)
+
+ _, fundingOutput, err = input.GenTaprootFundingScript(
+ carolKey, daveKey, int64(chanSize),
+ fn.None[chainhash.Hash](),
+ )
+ require.NoError(h, err)
+
+ musig2 = true
+ } else {
+ _, fundingOutput, err = input.GenFundingPkScript(
+ carolFundingKey.RawKeyBytes, daveFundingKey.RawKeyBytes,
+ int64(chanSize),
+ )
+ require.NoError(h, err)
+ }
+
+ var txid *chainhash.Hash
+ targetOutputs := []*wire.TxOut{fundingOutput}
+ if publish {
+ txid = h.SendOutputsWithoutChange(targetOutputs, 5)
+ } else {
+ tx := h.CreateTransaction(targetOutputs, 5)
+
+ txHash := tx.TxHash()
+ txid = &txHash
+ }
+
+ // At this point, we can being our external channel funding workflow.
+ // We'll start by generating a pending channel ID externally that will
+ // be used to track this new funding type.
+ pendingChanID := h.Random32Bytes()
+
+ // Now that we have the pending channel ID, Dave (our responder) will
+ // register the intent to receive a new channel funding workflow using
+ // the pending channel ID.
+ chanPoint := &lnrpc.ChannelPoint{
+ FundingTxid: &lnrpc.ChannelPoint_FundingTxidBytes{
+ FundingTxidBytes: txid[:],
+ },
+ }
+ chanPointShim := &lnrpc.ChanPointShim{
+ Amt: int64(chanSize),
+ ChanPoint: chanPoint,
+ LocalKey: &lnrpc.KeyDescriptor{
+ RawKeyBytes: daveFundingKey.RawKeyBytes,
+ KeyLoc: &lnrpc.KeyLocator{
+ KeyFamily: daveFundingKey.KeyLoc.KeyFamily,
+ KeyIndex: daveFundingKey.KeyLoc.KeyIndex,
+ },
+ },
+ RemoteKey: carolFundingKey.RawKeyBytes,
+ PendingChanId: pendingChanID,
+ ThawHeight: thawHeight,
+ Musig2: musig2,
+ }
+ fundingShim := &lnrpc.FundingShim{
+ Shim: &lnrpc.FundingShim_ChanPointShim{
+ ChanPointShim: chanPointShim,
+ },
+ }
+ bob.RPC.FundingStateStep(&lnrpc.FundingTransitionMsg{
+ Trigger: &lnrpc.FundingTransitionMsg_ShimRegister{
+ ShimRegister: fundingShim,
+ },
+ })
+
+ // If we attempt to register the same shim (has the same pending chan
+ // ID), then we should get an error.
+ bob.RPC.FundingStateStepAssertErr(&lnrpc.FundingTransitionMsg{
+ Trigger: &lnrpc.FundingTransitionMsg_ShimRegister{
+ ShimRegister: fundingShim,
+ },
+ })
+
+ // We'll take the chan point shim we just registered for Dave (the
+ // responder), and swap the local/remote keys before we feed it in as
+ // Carol's funding shim as the initiator.
+ fundingShim.GetChanPointShim().LocalKey = &lnrpc.KeyDescriptor{
+ RawKeyBytes: carolFundingKey.RawKeyBytes,
+ KeyLoc: &lnrpc.KeyLocator{
+ KeyFamily: carolFundingKey.KeyLoc.KeyFamily,
+ KeyIndex: carolFundingKey.KeyLoc.KeyIndex,
+ },
+ }
+ fundingShim.GetChanPointShim().RemoteKey = daveFundingKey.RawKeyBytes
+
+ return fundingShim, chanPoint
+}
diff --git a/lntest/harness_assertion.go b/lntest/harness_assertion.go
index 11cbefdd5c0..b99f461d185 100644
--- a/lntest/harness_assertion.go
+++ b/lntest/harness_assertion.go
@@ -29,6 +29,7 @@ import (
"github.com/lightningnetwork/lnd/lntest/rpc"
"github.com/lightningnetwork/lnd/lntest/wait"
"github.com/lightningnetwork/lnd/lntypes"
+ "github.com/lightningnetwork/lnd/lnutils"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)
@@ -738,15 +739,19 @@ func (h *HarnessTest) AssertStreamChannelForceClosed(hn *node.HarnessNode,
channeldb.ChanStatusLocalCloseInitiator.String(),
"channel not coop broadcasted")
+ // Get the closing txid.
+ closeTxid, err := chainhash.NewHashFromStr(resp.ClosingTxid)
+ require.NoError(h, err)
+
// We'll now, generate a single block, wait for the final close status
// update, then ensure that the closing transaction was included in the
// block.
- block := h.MineBlocksAndAssertNumTxes(1, 1)[0]
+ closeTx := h.AssertTxInMempool(*closeTxid)
+ h.MineBlockWithTx(closeTx)
// Consume one close event and assert the closing txid can be found in
// the block.
closingTxid := h.WaitForChannelCloseEvent(stream)
- h.AssertTxInBlock(block, closingTxid)
// We should see zero waiting close channels and 1 pending force close
// channels now.
@@ -1309,58 +1314,6 @@ func (h *HarnessTest) AssertNumActiveHtlcs(hn *node.HarnessNode, num int) {
hn.Name())
}
-// AssertActiveHtlcs makes sure the node has the _exact_ HTLCs matching
-// payHashes on _all_ their channels.
-func (h *HarnessTest) AssertActiveHtlcs(hn *node.HarnessNode,
- payHashes ...[]byte) {
-
- err := wait.NoError(func() error {
- // We require the RPC call to be succeeded and won't wait for
- // it as it's an unexpected behavior.
- req := &lnrpc.ListChannelsRequest{}
- nodeChans := hn.RPC.ListChannels(req)
-
- for _, ch := range nodeChans.Channels {
- // Record all payment hashes active for this channel.
- htlcHashes := make(map[string]struct{})
-
- for _, htlc := range ch.PendingHtlcs {
- h := hex.EncodeToString(htlc.HashLock)
- _, ok := htlcHashes[h]
- if ok {
- return fmt.Errorf("duplicate HashLock "+
- "in PendingHtlcs: %v",
- ch.PendingHtlcs)
- }
- htlcHashes[h] = struct{}{}
- }
-
- // Channel should have exactly the payHashes active.
- if len(payHashes) != len(htlcHashes) {
- return fmt.Errorf("node [%s:%x] had %v "+
- "htlcs active, expected %v",
- hn.Name(), hn.PubKey[:],
- len(htlcHashes), len(payHashes))
- }
-
- // Make sure all the payHashes are active.
- for _, payHash := range payHashes {
- h := hex.EncodeToString(payHash)
- if _, ok := htlcHashes[h]; ok {
- continue
- }
-
- return fmt.Errorf("node [%s:%x] didn't have: "+
- "the payHash %v active", hn.Name(),
- hn.PubKey[:], h)
- }
- }
-
- return nil
- }, DefaultTimeout)
- require.NoError(h, err, "timeout checking active HTLCs")
-}
-
// AssertIncomingHTLCActive asserts the node has a pending incoming HTLC in the
// given channel. Returns the HTLC if found and active.
func (h *HarnessTest) AssertIncomingHTLCActive(hn *node.HarnessNode,
@@ -1609,8 +1562,9 @@ func (h *HarnessTest) AssertNumHTLCsAndStage(hn *node.HarnessNode,
}
if len(target.PendingHtlcs) != num {
- return fmt.Errorf("got %d pending htlcs, want %d",
- len(target.PendingHtlcs), num)
+ return fmt.Errorf("got %d pending htlcs, want %d, %s",
+ len(target.PendingHtlcs), num,
+ lnutils.SpewLogClosure(target.PendingHtlcs)())
}
for i, htlc := range target.PendingHtlcs {
@@ -2786,3 +2740,48 @@ func (h *HarnessTest) FindSweepingTxns(txns []*wire.MsgTx,
return sweepTxns
}
+
+// AssertForceCloseAndAnchorTxnsInMempool asserts that the force close and
+// anchor sweep txns are found in the mempool and returns the force close tx
+// and the anchor sweep tx.
+func (h *HarnessTest) AssertForceCloseAndAnchorTxnsInMempool() (*wire.MsgTx,
+ *wire.MsgTx) {
+
+ // Assert there are two txns in the mempool.
+ txns := h.GetNumTxsFromMempool(2)
+
+ // isParentAndChild checks whether there is an input used in the
+ // assumed child tx by checking every input's previous outpoint against
+ // the assumed parentTxid.
+ isParentAndChild := func(parent, child *wire.MsgTx) bool {
+ parentTxid := parent.TxHash()
+
+ for _, inp := range child.TxIn {
+ if inp.PreviousOutPoint.Hash == parentTxid {
+ // Found a match, this is indeed the anchor
+ // sweeping tx so we return it here.
+ return true
+ }
+ }
+
+ return false
+ }
+
+ switch {
+ // Assume the first one is the closing tx and the second one is the
+ // anchor sweeping tx.
+ case isParentAndChild(txns[0], txns[1]):
+ return txns[0], txns[1]
+
+ // Assume the first one is the anchor sweeping tx and the second one is
+ // the closing tx.
+ case isParentAndChild(txns[1], txns[0]):
+ return txns[1], txns[0]
+
+ // Unrelated txns found, fail the test.
+ default:
+ h.Fatalf("the two txns not related: %v", txns)
+
+ return nil, nil
+ }
+}
diff --git a/lntest/harness_miner.go b/lntest/harness_miner.go
index 22b2b95acc4..17fd864ed78 100644
--- a/lntest/harness_miner.go
+++ b/lntest/harness_miner.go
@@ -196,7 +196,8 @@ func (h *HarnessTest) mineTillForceCloseResolved(hn *node.HarnessNode) {
return nil
}, DefaultTimeout)
- require.NoErrorf(h, err, "assert force close resolved timeout")
+ require.NoErrorf(h, err, "%s: assert force close resolved timeout",
+ hn.Name())
}
// AssertTxInMempool asserts a given transaction can be found in the mempool.
diff --git a/lntest/node/config.go b/lntest/node/config.go
index 32f24395b6b..6cba60941c3 100644
--- a/lntest/node/config.go
+++ b/lntest/node/config.go
@@ -41,6 +41,40 @@ var (
btcdExecutable = flag.String(
"btcdexec", "", "full path to btcd binary",
)
+
+ // CfgLegacy specifies the config used to create a node that uses the
+ // legacy channel format.
+ CfgLegacy = []string{"--protocol.legacy.committweak"}
+
+ // CfgStaticRemoteKey specifies the config used to create a node that
+ // uses the static remote key feature.
+ CfgStaticRemoteKey = []string{}
+
+ // CfgAnchor specifies the config used to create a node that uses the
+ // anchor output feature.
+ CfgAnchor = []string{"--protocol.anchors"}
+
+ // CfgLeased specifies the config used to create a node that uses the
+ // leased channel feature.
+ CfgLeased = []string{
+ "--protocol.anchors",
+ "--protocol.script-enforced-lease",
+ }
+
+ // CfgSimpleTaproot specifies the config used to create a node that
+ // uses the simple taproot feature.
+ CfgSimpleTaproot = []string{
+ "--protocol.anchors",
+ "--protocol.simple-taproot-chans",
+ }
+
+ // CfgZeroConf specifies the config used to create a node that uses the
+ // zero-conf channel feature.
+ CfgZeroConf = []string{
+ "--protocol.anchors",
+ "--protocol.option-scid-alias",
+ "--protocol.zero-conf",
+ }
)
type DatabaseBackend int
diff --git a/lnutils/log.go b/lnutils/log.go
index a32738bdf4b..a588bf3ce34 100644
--- a/lnutils/log.go
+++ b/lnutils/log.go
@@ -1,6 +1,10 @@
package lnutils
-import "github.com/davecgh/go-spew/spew"
+import (
+ "strings"
+
+ "github.com/davecgh/go-spew/spew"
+)
// LogClosure is used to provide a closure over expensive logging operations so
// don't have to be performed when the logging level doesn't warrant it.
@@ -25,3 +29,10 @@ func SpewLogClosure(a any) LogClosure {
return spew.Sdump(a)
}
}
+
+// NewSeparatorClosure returns a new closure that logs a separator line.
+func NewSeparatorClosure() LogClosure {
+ return func() string {
+ return strings.Repeat("=", 80)
+ }
+}
diff --git a/lnwallet/wallet.go b/lnwallet/wallet.go
index 96ea85cf9e2..2646d7c8f00 100644
--- a/lnwallet/wallet.go
+++ b/lnwallet/wallet.go
@@ -733,7 +733,7 @@ func (l *LightningWallet) RegisterFundingIntent(expectedID [32]byte,
}
if _, ok := l.fundingIntents[expectedID]; ok {
- return fmt.Errorf("%w: already has intent registered: %v",
+ return fmt.Errorf("%w: already has intent registered: %x",
ErrDuplicatePendingChanID, expectedID[:])
}
diff --git a/log.go b/log.go
index ae2d2d811f1..d3c93b43251 100644
--- a/log.go
+++ b/log.go
@@ -9,6 +9,7 @@ import (
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/autopilot"
"github.com/lightningnetwork/lnd/build"
+ "github.com/lightningnetwork/lnd/chainio"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/chainreg"
"github.com/lightningnetwork/lnd/chanacceptor"
@@ -198,6 +199,7 @@ func SetupLoggers(root *build.SubLoggerManager, interceptor signal.Interceptor)
root, blindedpath.Subsystem, interceptor, blindedpath.UseLogger,
)
AddV1SubLogger(root, graphdb.Subsystem, interceptor, graphdb.UseLogger)
+ AddSubLogger(root, chainio.Subsystem, interceptor, chainio.UseLogger)
}
// AddSubLogger is a helper method to conveniently create and register the
diff --git a/server.go b/server.go
index 799bfde6b87..9fd0b7a006b 100644
--- a/server.go
+++ b/server.go
@@ -28,6 +28,7 @@ import (
"github.com/lightningnetwork/lnd/aliasmgr"
"github.com/lightningnetwork/lnd/autopilot"
"github.com/lightningnetwork/lnd/brontide"
+ "github.com/lightningnetwork/lnd/chainio"
"github.com/lightningnetwork/lnd/chainreg"
"github.com/lightningnetwork/lnd/chanacceptor"
"github.com/lightningnetwork/lnd/chanbackup"
@@ -356,6 +357,10 @@ type server struct {
// txPublisher is a publisher with fee-bumping capability.
txPublisher *sweep.TxPublisher
+ // blockbeatDispatcher is a block dispatcher that notifies subscribers
+ // of new blocks.
+ blockbeatDispatcher *chainio.BlockbeatDispatcher
+
quit chan struct{}
wg sync.WaitGroup
@@ -623,6 +628,9 @@ func newServer(cfg *Config, listenAddrs []net.Addr,
readPool: readPool,
chansToRestore: chansToRestore,
+ blockbeatDispatcher: chainio.NewBlockbeatDispatcher(
+ cc.ChainNotifier,
+ ),
channelNotifier: channelnotifier.New(
dbs.ChanStateDB.ChannelStateDB(),
),
@@ -665,6 +673,17 @@ func newServer(cfg *Config, listenAddrs []net.Addr,
quit: make(chan struct{}),
}
+ // Start the low-level services once they are initialized.
+ //
+ // TODO(yy): break the server startup into four steps,
+ // 1. init the low-level services.
+ // 2. start the low-level services.
+ // 3. init the high-level services.
+ // 4. start the high-level services.
+ if err := s.startLowLevelServices(); err != nil {
+ return nil, err
+ }
+
currentHash, currentHeight, err := s.cc.ChainIO.GetBestBlock()
if err != nil {
return nil, err
@@ -1813,6 +1832,9 @@ func newServer(cfg *Config, listenAddrs []net.Addr,
}
s.connMgr = cmgr
+ // Finally, register the subsystems in blockbeat.
+ s.registerBlockConsumers()
+
return s, nil
}
@@ -1845,6 +1867,25 @@ func (s *server) UpdateRoutingConfig(cfg *routing.MissionControlConfig) {
routerCfg.MaxMcHistory = cfg.MaxMcHistory
}
+// registerBlockConsumers registers the subsystems that consume block events.
+// By calling `RegisterQueue`, a list of subsystems are registered in the
+// blockbeat for block notifications. When a new block arrives, the subsystems
+// in the same queue are notified sequentially, and different queues are
+// notified concurrently.
+//
+// NOTE: To put a subsystem in a different queue, create a slice and pass it to
+// a new `RegisterQueue` call.
+func (s *server) registerBlockConsumers() {
+ // In this queue, when a new block arrives, it will be received and
+ // processed in this order: chainArb -> sweeper -> txPublisher.
+ consumers := []chainio.Consumer{
+ s.chainArb,
+ s.sweeper,
+ s.txPublisher,
+ }
+ s.blockbeatDispatcher.RegisterQueue(consumers)
+}
+
// signAliasUpdate takes a ChannelUpdate and returns the signature. This is
// used for option_scid_alias channels where the ChannelUpdate to be sent back
// may differ from what is on disk.
@@ -2067,12 +2108,41 @@ func (c cleaner) run() {
}
}
+// startLowLevelServices starts the low-level services of the server. These
+// services must be started successfully before running the main server. The
+// services are,
+// 1. the chain notifier.
+//
+// TODO(yy): identify and add more low-level services here.
+func (s *server) startLowLevelServices() error {
+ var startErr error
+
+ cleanup := cleaner{}
+
+ cleanup = cleanup.add(s.cc.ChainNotifier.Stop)
+ if err := s.cc.ChainNotifier.Start(); err != nil {
+ startErr = err
+ }
+
+ if startErr != nil {
+ cleanup.run()
+ }
+
+ return startErr
+}
+
// Start starts the main daemon server, all requested listeners, and any helper
// goroutines.
// NOTE: This function is safe for concurrent access.
//
//nolint:funlen
func (s *server) Start() error {
+ // Get the current blockbeat.
+ beat, err := s.getStartingBeat()
+ if err != nil {
+ return err
+ }
+
var startErr error
// If one sub system fails to start, the following code ensures that the
@@ -2126,12 +2196,6 @@ func (s *server) Start() error {
return
}
- cleanup = cleanup.add(s.cc.ChainNotifier.Stop)
- if err := s.cc.ChainNotifier.Start(); err != nil {
- startErr = err
- return
- }
-
cleanup = cleanup.add(s.cc.BestBlockTracker.Stop)
if err := s.cc.BestBlockTracker.Start(); err != nil {
startErr = err
@@ -2167,13 +2231,13 @@ func (s *server) Start() error {
}
cleanup = cleanup.add(s.txPublisher.Stop)
- if err := s.txPublisher.Start(); err != nil {
+ if err := s.txPublisher.Start(beat); err != nil {
startErr = err
return
}
cleanup = cleanup.add(s.sweeper.Stop)
- if err := s.sweeper.Start(); err != nil {
+ if err := s.sweeper.Start(beat); err != nil {
startErr = err
return
}
@@ -2218,7 +2282,7 @@ func (s *server) Start() error {
}
cleanup = cleanup.add(s.chainArb.Stop)
- if err := s.chainArb.Start(); err != nil {
+ if err := s.chainArb.Start(beat); err != nil {
startErr = err
return
}
@@ -2459,6 +2523,17 @@ func (s *server) Start() error {
srvrLog.Infof("Auto peer bootstrapping is disabled")
}
+ // Start the blockbeat after all other subsystems have been
+ // started so they are ready to receive new blocks.
+ cleanup = cleanup.add(func() error {
+ s.blockbeatDispatcher.Stop()
+ return nil
+ })
+ if err := s.blockbeatDispatcher.Start(); err != nil {
+ startErr = err
+ return
+ }
+
// Set the active flag now that we've completed the full
// startup.
atomic.StoreInt32(&s.active, 1)
@@ -2483,6 +2558,9 @@ func (s *server) Stop() error {
// Shutdown connMgr first to prevent conns during shutdown.
s.connMgr.Stop()
+ // Stop dispatching blocks to other systems immediately.
+ s.blockbeatDispatcher.Stop()
+
// Shutdown the wallet, funding manager, and the rpc server.
if err := s.chanStatusMgr.Stop(); err != nil {
srvrLog.Warnf("failed to stop chanStatusMgr: %v", err)
@@ -5152,3 +5230,35 @@ func (s *server) fetchClosedChannelSCIDs() map[lnwire.ShortChannelID]struct{} {
return closedSCIDs
}
+
+// getStartingBeat returns the current beat. This is used during the startup to
+// initialize blockbeat consumers.
+func (s *server) getStartingBeat() (*chainio.Beat, error) {
+ // beat is the current blockbeat.
+ var beat *chainio.Beat
+
+ // We should get a notification with the current best block immediately
+ // by passing a nil block.
+ blockEpochs, err := s.cc.ChainNotifier.RegisterBlockEpochNtfn(nil)
+ if err != nil {
+ return beat, fmt.Errorf("register block epoch ntfn: %w", err)
+ }
+ defer blockEpochs.Cancel()
+
+ // We registered for the block epochs with a nil request. The notifier
+ // should send us the current best block immediately. So we need to
+ // wait for it here because we need to know the current best height.
+ select {
+ case bestBlock := <-blockEpochs.Epochs:
+ srvrLog.Infof("Received initial block %v at height %d",
+ bestBlock.Hash, bestBlock.Height)
+
+ // Update the current blockbeat.
+ beat = chainio.NewBeat(*bestBlock)
+
+ case <-s.quit:
+ srvrLog.Debug("LND shutting down")
+ }
+
+ return beat, nil
+}
diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go
index 7bb58ae29e0..57f9f6e3037 100644
--- a/sweep/fee_bumper.go
+++ b/sweep/fee_bumper.go
@@ -12,6 +12,7 @@ import (
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/chain"
+ "github.com/lightningnetwork/lnd/chainio"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/input"
@@ -65,7 +66,7 @@ type Bumper interface {
// and monitors its confirmation status for potential fee bumping. It
// returns a chan that the caller can use to receive updates about the
// broadcast result and potential RBF attempts.
- Broadcast(req *BumpRequest) (<-chan *BumpResult, error)
+ Broadcast(req *BumpRequest) <-chan *BumpResult
}
// BumpEvent represents the event of a fee bumping attempt.
@@ -75,7 +76,17 @@ const (
// TxPublished is sent when the broadcast attempt is finished.
TxPublished BumpEvent = iota
- // TxFailed is sent when the broadcast attempt fails.
+ // TxFailed is sent when the tx has encountered a fee-related error
+ // during its creation or broadcast, or an internal error from the fee
+ // bumper. In either case the inputs in this tx should be retried with
+ // either a different grouping strategy or an increased budget.
+ //
+ // NOTE: We also send this event when there's a third party spend
+ // event, and the sweeper will handle cleaning this up once it's
+ // confirmed.
+ //
+ // TODO(yy): Remove the above usage once we remove sweeping non-CPFP
+ // anchors.
TxFailed
// TxReplaced is sent when the original tx is replaced by a new one.
@@ -84,6 +95,11 @@ const (
// TxConfirmed is sent when the tx is confirmed.
TxConfirmed
+ // TxFatal is sent when the inputs in this tx cannot be retried. Txns
+ // will end up in this state if they have encountered a non-fee related
+ // error, which means they cannot be retried with increased budget.
+ TxFatal
+
// sentinalEvent is used to check if an event is unknown.
sentinalEvent
)
@@ -99,6 +115,8 @@ func (e BumpEvent) String() string {
return "Replaced"
case TxConfirmed:
return "Confirmed"
+ case TxFatal:
+ return "Fatal"
default:
return "Unknown"
}
@@ -136,6 +154,10 @@ type BumpRequest struct {
// ExtraTxOut tracks if this bump request has an optional set of extra
// outputs to add to the transaction.
ExtraTxOut fn.Option[SweepOutput]
+
+ // Immediate is used to specify that the tx should be broadcast
+ // immediately.
+ Immediate bool
}
// MaxFeeRateAllowed returns the maximum fee rate allowed for the given
@@ -246,10 +268,22 @@ type BumpResult struct {
requestID uint64
}
+// String returns a human-readable string for the result.
+func (b *BumpResult) String() string {
+ desc := fmt.Sprintf("Event=%v", b.Event)
+ if b.Tx != nil {
+ desc += fmt.Sprintf(", Tx=%v", b.Tx.TxHash())
+ }
+
+ return fmt.Sprintf("[%s]", desc)
+}
+
// Validate validates the BumpResult so it's safe to use.
func (b *BumpResult) Validate() error {
- // Every result must have a tx.
- if b.Tx == nil {
+ isFailureEvent := b.Event == TxFailed || b.Event == TxFatal
+
+ // Every result must have a tx except the fatal or failed case.
+ if b.Tx == nil && !isFailureEvent {
return fmt.Errorf("%w: nil tx", ErrInvalidBumpResult)
}
@@ -263,8 +297,8 @@ func (b *BumpResult) Validate() error {
return fmt.Errorf("%w: nil replacing tx", ErrInvalidBumpResult)
}
- // If it's a failed event, it must have an error.
- if b.Event == TxFailed && b.Err == nil {
+ // If it's a failed or fatal event, it must have an error.
+ if isFailureEvent && b.Err == nil {
return fmt.Errorf("%w: nil error", ErrInvalidBumpResult)
}
@@ -311,6 +345,10 @@ type TxPublisher struct {
started atomic.Bool
stopped atomic.Bool
+ // Embed the blockbeat consumer struct to get access to the method
+ // `NotifyBlockProcessed` and the `BlockbeatChan`.
+ chainio.BeatConsumer
+
wg sync.WaitGroup
// cfg specifies the configuration of the TxPublisher.
@@ -338,14 +376,22 @@ type TxPublisher struct {
// Compile-time constraint to ensure TxPublisher implements Bumper.
var _ Bumper = (*TxPublisher)(nil)
+// Compile-time check for the chainio.Consumer interface.
+var _ chainio.Consumer = (*TxPublisher)(nil)
+
// NewTxPublisher creates a new TxPublisher.
func NewTxPublisher(cfg TxPublisherConfig) *TxPublisher {
- return &TxPublisher{
+ tp := &TxPublisher{
cfg: &cfg,
records: lnutils.SyncMap[uint64, *monitorRecord]{},
subscriberChans: lnutils.SyncMap[uint64, chan *BumpResult]{},
quit: make(chan struct{}),
}
+
+ // Mount the block consumer.
+ tp.BeatConsumer = chainio.NewBeatConsumer(tp.quit, tp.Name())
+
+ return tp
}
// isNeutrinoBackend checks if the wallet backend is neutrino.
@@ -353,60 +399,69 @@ func (t *TxPublisher) isNeutrinoBackend() bool {
return t.cfg.Wallet.BackEnd() == "neutrino"
}
-// Broadcast is used to publish the tx created from the given inputs. It will,
-// 1. init a fee function based on the given strategy.
-// 2. create an RBF-compliant tx and monitor it for confirmation.
-// 3. notify the initial broadcast result back to the caller.
-// The initial broadcast is guaranteed to be RBF-compliant unless the budget
-// specified cannot cover the fee.
+// Broadcast is used to publish the tx created from the given inputs. It will
+// register the broadcast request and return a chan to the caller to subscribe
+// the broadcast result. The initial broadcast is guaranteed to be
+// RBF-compliant unless the budget specified cannot cover the fee.
//
// NOTE: part of the Bumper interface.
-func (t *TxPublisher) Broadcast(req *BumpRequest) (<-chan *BumpResult, error) {
- log.Tracef("Received broadcast request: %s", lnutils.SpewLogClosure(
- req))
+func (t *TxPublisher) Broadcast(req *BumpRequest) <-chan *BumpResult {
+ log.Tracef("Received broadcast request: %s",
+ lnutils.SpewLogClosure(req))
- // Attempt an initial broadcast which is guaranteed to comply with the
- // RBF rules.
- result, err := t.initialBroadcast(req)
- if err != nil {
- log.Errorf("Initial broadcast failed: %v", err)
-
- return nil, err
- }
+ // Store the request.
+ requestID, record := t.storeInitialRecord(req)
// Create a chan to send the result to the caller.
subscriber := make(chan *BumpResult, 1)
- t.subscriberChans.Store(result.requestID, subscriber)
+ t.subscriberChans.Store(requestID, subscriber)
- // Send the initial broadcast result to the caller.
- t.handleResult(result)
+ // Publish the tx immediately if specified.
+ if req.Immediate {
+ t.handleInitialBroadcast(record, requestID)
+ }
+
+ return subscriber
+}
+
+// storeInitialRecord initializes a monitor record and saves it in the map.
+func (t *TxPublisher) storeInitialRecord(req *BumpRequest) (
+ uint64, *monitorRecord) {
+
+ // Increase the request counter.
+ //
+ // NOTE: this is the only place where we increase the counter.
+ requestID := t.requestCounter.Add(1)
+
+ // Register the record.
+ record := &monitorRecord{req: req}
+ t.records.Store(requestID, record)
+
+ return requestID, record
+}
- return subscriber, nil
+// NOTE: part of the `chainio.Consumer` interface.
+func (t *TxPublisher) Name() string {
+ return "TxPublisher"
}
-// initialBroadcast initializes a fee function, creates an RBF-compliant tx and
-// broadcasts it.
-func (t *TxPublisher) initialBroadcast(req *BumpRequest) (*BumpResult, error) {
+// initializeTx initializes a fee function and creates an RBF-compliant tx. If
+// succeeded, the initial tx is stored in the records map.
+func (t *TxPublisher) initializeTx(requestID uint64, req *BumpRequest) error {
// Create a fee bumping algorithm to be used for future RBF.
feeAlgo, err := t.initializeFeeFunction(req)
if err != nil {
- return nil, fmt.Errorf("init fee function: %w", err)
+ return fmt.Errorf("init fee function: %w", err)
}
// Create the initial tx to be broadcasted. This tx is guaranteed to
// comply with the RBF restrictions.
- requestID, err := t.createRBFCompliantTx(req, feeAlgo)
+ err = t.createRBFCompliantTx(requestID, req, feeAlgo)
if err != nil {
- return nil, fmt.Errorf("create RBF-compliant tx: %w", err)
+ return fmt.Errorf("create RBF-compliant tx: %w", err)
}
- // Broadcast the tx and return the monitored record.
- result, err := t.broadcast(requestID)
- if err != nil {
- return nil, fmt.Errorf("broadcast sweep tx: %w", err)
- }
-
- return result, nil
+ return nil
}
// initializeFeeFunction initializes a fee function to be used for this request
@@ -442,8 +497,8 @@ func (t *TxPublisher) initializeFeeFunction(
// so by creating a tx, validate it using `TestMempoolAccept`, and bump its fee
// and redo the process until the tx is valid, or return an error when non-RBF
// related errors occur or the budget has been used up.
-func (t *TxPublisher) createRBFCompliantTx(req *BumpRequest,
- f FeeFunction) (uint64, error) {
+func (t *TxPublisher) createRBFCompliantTx(requestID uint64, req *BumpRequest,
+ f FeeFunction) error {
for {
// Create a new tx with the given fee rate and check its
@@ -452,18 +507,19 @@ func (t *TxPublisher) createRBFCompliantTx(req *BumpRequest,
switch {
case err == nil:
- // The tx is valid, return the request ID.
- requestID := t.storeRecord(
- sweepCtx.tx, req, f, sweepCtx.fee,
+ // The tx is valid, store it.
+ t.storeRecord(
+ requestID, sweepCtx.tx, req, f, sweepCtx.fee,
sweepCtx.outpointToTxIndex,
)
- log.Infof("Created tx %v for %v inputs: feerate=%v, "+
- "fee=%v, inputs=%v", sweepCtx.tx.TxHash(),
- len(req.Inputs), f.FeeRate(), sweepCtx.fee,
+ log.Infof("Created initial sweep tx=%v for %v inputs: "+
+ "feerate=%v, fee=%v, inputs:\n%v",
+ sweepCtx.tx.TxHash(), len(req.Inputs),
+ f.FeeRate(), sweepCtx.fee,
inputTypeSummary(req.Inputs))
- return requestID, nil
+ return nil
// If the error indicates the fees paid is not enough, we will
// ask the fee function to increase the fee rate and retry.
@@ -494,7 +550,7 @@ func (t *TxPublisher) createRBFCompliantTx(req *BumpRequest,
// cluster these inputs differetly.
increased, err = f.Increment()
if err != nil {
- return 0, err
+ return err
}
}
@@ -504,21 +560,15 @@ func (t *TxPublisher) createRBFCompliantTx(req *BumpRequest,
// mempool acceptance.
default:
log.Debugf("Failed to create RBF-compliant tx: %v", err)
- return 0, err
+ return err
}
}
}
// storeRecord stores the given record in the records map.
-func (t *TxPublisher) storeRecord(tx *wire.MsgTx, req *BumpRequest,
- f FeeFunction, fee btcutil.Amount,
- outpointToTxIndex map[wire.OutPoint]int) uint64 {
-
- // Increase the request counter.
- //
- // NOTE: this is the only place where we increase the
- // counter.
- requestID := t.requestCounter.Add(1)
+func (t *TxPublisher) storeRecord(requestID uint64, tx *wire.MsgTx,
+ req *BumpRequest, f FeeFunction, fee btcutil.Amount,
+ outpointToTxIndex map[wire.OutPoint]int) {
// Register the record.
t.records.Store(requestID, &monitorRecord{
@@ -528,8 +578,6 @@ func (t *TxPublisher) storeRecord(tx *wire.MsgTx, req *BumpRequest,
fee: fee,
outpointToTxIndex: outpointToTxIndex,
})
-
- return requestID
}
// createAndCheckTx creates a tx based on the given inputs, change output
@@ -659,8 +707,7 @@ func (t *TxPublisher) notifyResult(result *BumpResult) {
return
}
- log.Debugf("Sending result for requestID=%v, tx=%v", id,
- result.Tx.TxHash())
+ log.Debugf("Sending result %v for requestID=%v", result, id)
select {
// Send the result to the subscriber.
@@ -678,20 +725,31 @@ func (t *TxPublisher) notifyResult(result *BumpResult) {
func (t *TxPublisher) removeResult(result *BumpResult) {
id := result.requestID
- // Remove the record from the maps if there's an error. This means this
- // tx has failed its broadcast and cannot be retried. There are two
- // cases,
- // - when the budget cannot cover the fee.
- // - when a non-RBF related error occurs.
+ var txid chainhash.Hash
+ if result.Tx != nil {
+ txid = result.Tx.TxHash()
+ }
+
+ // Remove the record from the maps if there's an error or the tx is
+ // confirmed. When there's an error, it means this tx has failed its
+ // broadcast and cannot be retried. There are two cases it may fail,
+ // - when the budget cannot cover the increased fee calculated by the
+ // fee function, hence the budget is used up.
+ // - when a non-fee related error returned from PublishTransaction.
switch result.Event {
case TxFailed:
log.Errorf("Removing monitor record=%v, tx=%v, due to err: %v",
- id, result.Tx.TxHash(), result.Err)
+ id, txid, result.Err)
case TxConfirmed:
- // Remove the record is the tx is confirmed.
+ // Remove the record if the tx is confirmed.
log.Debugf("Removing confirmed monitor record=%v, tx=%v", id,
- result.Tx.TxHash())
+ txid)
+
+ case TxFatal:
+ // Remove the record if there's an error.
+ log.Debugf("Removing monitor record=%v due to fatal err: %v",
+ id, result.Err)
// Do nothing if it's neither failed or confirmed.
default:
@@ -737,20 +795,18 @@ type monitorRecord struct {
// Start starts the publisher by subscribing to block epoch updates and kicking
// off the monitor loop.
-func (t *TxPublisher) Start() error {
+func (t *TxPublisher) Start(beat chainio.Blockbeat) error {
log.Info("TxPublisher starting...")
if t.started.Swap(true) {
return fmt.Errorf("TxPublisher started more than once")
}
- blockEvent, err := t.cfg.Notifier.RegisterBlockEpochNtfn(nil)
- if err != nil {
- return fmt.Errorf("register block epoch ntfn: %w", err)
- }
+ // Set the current height.
+ t.currentHeight.Store(beat.Height())
t.wg.Add(1)
- go t.monitor(blockEvent)
+ go t.monitor()
log.Debugf("TxPublisher started")
@@ -778,33 +834,25 @@ func (t *TxPublisher) Stop() error {
// to be bumped. If so, it will attempt to bump the fee of the tx.
//
// NOTE: Must be run as a goroutine.
-func (t *TxPublisher) monitor(blockEvent *chainntnfs.BlockEpochEvent) {
- defer blockEvent.Cancel()
+func (t *TxPublisher) monitor() {
defer t.wg.Done()
for {
select {
- case epoch, ok := <-blockEvent.Epochs:
- if !ok {
- // We should stop the publisher before stopping
- // the chain service. Otherwise it indicates an
- // error.
- log.Error("Block epoch channel closed, exit " +
- "monitor")
-
- return
- }
-
- log.Debugf("TxPublisher received new block: %v",
- epoch.Height)
+ case beat := <-t.BlockbeatChan:
+ height := beat.Height()
+ log.Debugf("TxPublisher received new block: %v", height)
// Update the best known height for the publisher.
- t.currentHeight.Store(epoch.Height)
+ t.currentHeight.Store(height)
// Check all monitored txns to see if any of them needs
// to be bumped.
t.processRecords()
+ // Notify we've processed the block.
+ t.NotifyBlockProcessed(beat, nil)
+
case <-t.quit:
log.Debug("Fee bumper stopped, exit monitor")
return
@@ -819,18 +867,27 @@ func (t *TxPublisher) processRecords() {
// confirmed.
confirmedRecords := make(map[uint64]*monitorRecord)
- // feeBumpRecords stores a map of the records which need to be bumped.
+ // feeBumpRecords stores a map of records which need to be bumped.
feeBumpRecords := make(map[uint64]*monitorRecord)
- // failedRecords stores a map of the records which has inputs being
- // spent by a third party.
+ // failedRecords stores a map of records which has inputs being spent
+ // by a third party.
//
// NOTE: this is only used for neutrino backend.
failedRecords := make(map[uint64]*monitorRecord)
+ // initialRecords stores a map of records which are being created and
+ // published for the first time.
+ initialRecords := make(map[uint64]*monitorRecord)
+
// visitor is a helper closure that visits each record and divides them
// into two groups.
visitor := func(requestID uint64, r *monitorRecord) error {
+ if r.tx == nil {
+ initialRecords[requestID] = r
+ return nil
+ }
+
log.Tracef("Checking monitor recordID=%v for tx=%v", requestID,
r.tx.TxHash())
@@ -858,17 +915,20 @@ func (t *TxPublisher) processRecords() {
return nil
}
- // Iterate through all the records and divide them into two groups.
+ // Iterate through all the records and divide them into four groups.
t.records.ForEach(visitor)
+ // Handle the initial broadcast.
+ for requestID, r := range initialRecords {
+ t.handleInitialBroadcast(r, requestID)
+ }
+
// For records that are confirmed, we'll notify the caller about this
// result.
for requestID, r := range confirmedRecords {
- rec := r
-
log.Debugf("Tx=%v is confirmed", r.tx.TxHash())
t.wg.Add(1)
- go t.handleTxConfirmed(rec, requestID)
+ go t.handleTxConfirmed(r, requestID)
}
// Get the current height to be used in the following goroutines.
@@ -876,22 +936,18 @@ func (t *TxPublisher) processRecords() {
// For records that are not confirmed, we perform a fee bump if needed.
for requestID, r := range feeBumpRecords {
- rec := r
-
log.Debugf("Attempting to fee bump Tx=%v", r.tx.TxHash())
t.wg.Add(1)
- go t.handleFeeBumpTx(requestID, rec, currentHeight)
+ go t.handleFeeBumpTx(requestID, r, currentHeight)
}
// For records that are failed, we'll notify the caller about this
// result.
for requestID, r := range failedRecords {
- rec := r
-
log.Debugf("Tx=%v has inputs been spent by a third party, "+
"failing it now", r.tx.TxHash())
t.wg.Add(1)
- go t.handleThirdPartySpent(rec, requestID)
+ go t.handleThirdPartySpent(r, requestID)
}
}
@@ -916,6 +972,96 @@ func (t *TxPublisher) handleTxConfirmed(r *monitorRecord, requestID uint64) {
t.handleResult(result)
}
+// handleInitialTxError takes the error from `initializeTx` and decides the
+// bump event. It will construct a BumpResult and handles it.
+func (t *TxPublisher) handleInitialTxError(requestID uint64, err error) {
+ // We now decide what type of event to send.
+ var event BumpEvent
+
+ switch {
+ // When the error is due to a dust output, we'll send a TxFailed so
+ // these inputs can be retried with a different group in the next
+ // block.
+ case errors.Is(err, ErrTxNoOutput):
+ event = TxFailed
+
+ // When the error is due to budget being used up, we'll send a TxFailed
+ // so these inputs can be retried with a different group in the next
+ // block.
+ case errors.Is(err, ErrMaxPosition):
+ event = TxFailed
+
+ // When the error is due to zero fee rate delta, we'll send a TxFailed
+ // so these inputs can be retried in the next block.
+ case errors.Is(err, ErrZeroFeeRateDelta):
+ event = TxFailed
+
+ // Otherwise this is not a fee-related error and the tx cannot be
+ // retried. In that case we will fail ALL the inputs in this tx, which
+ // means they will be removed from the sweeper and never be tried
+ // again.
+ //
+ // TODO(yy): Find out which input is causing the failure and fail that
+ // one only.
+ default:
+ event = TxFatal
+ }
+
+ result := &BumpResult{
+ Event: event,
+ Err: err,
+ requestID: requestID,
+ }
+
+ t.handleResult(result)
+}
+
+// handleInitialBroadcast is called when a new request is received. It will
+// handle the initial tx creation and broadcast. In details,
+// 1. init a fee function based on the given strategy.
+// 2. create an RBF-compliant tx and monitor it for confirmation.
+// 3. notify the initial broadcast result back to the caller.
+func (t *TxPublisher) handleInitialBroadcast(r *monitorRecord,
+ requestID uint64) {
+
+ log.Debugf("Initial broadcast for requestID=%v", requestID)
+
+ var (
+ result *BumpResult
+ err error
+ )
+
+ // Attempt an initial broadcast which is guaranteed to comply with the
+ // RBF rules.
+ //
+ // Create the initial tx to be broadcasted.
+ err = t.initializeTx(requestID, r.req)
+ if err != nil {
+ log.Errorf("Initial broadcast failed: %v", err)
+
+ // We now handle the initialization error and exit.
+ t.handleInitialTxError(requestID, err)
+
+ return
+ }
+
+ // Successfully created the first tx, now broadcast it.
+ result, err = t.broadcast(requestID)
+ if err != nil {
+ // The broadcast failed, which can only happen if the tx record
+ // cannot be found or the aux sweeper returns an error. In
+ // either case, we will send back a TxFail event so these
+ // inputs can be retried.
+ result = &BumpResult{
+ Event: TxFailed,
+ Err: err,
+ requestID: requestID,
+ }
+ }
+
+ t.handleResult(result)
+}
+
// handleFeeBumpTx checks if the tx needs to be bumped, and if so, it will
// attempt to bump the fee of the tx.
//
@@ -1442,7 +1588,10 @@ func prepareSweepTx(inputs []input.Input, changePkScript lnwallet.AddrWithKey,
// Check if the lock time has reached
if lt > uint32(currentHeight) {
- return 0, noChange, noLocktime, ErrLocktimeImmature
+ return 0, noChange, noLocktime,
+ fmt.Errorf("%w: current height is %v, "+
+ "locktime is %v", ErrLocktimeImmature,
+ currentHeight, lt)
}
// If another input commits to a different locktime, they
diff --git a/sweep/fee_bumper_test.go b/sweep/fee_bumper_test.go
index c9196aee5a6..54c67dbe281 100644
--- a/sweep/fee_bumper_test.go
+++ b/sweep/fee_bumper_test.go
@@ -91,6 +91,12 @@ func TestBumpResultValidate(t *testing.T) {
}
require.ErrorIs(t, b.Validate(), ErrInvalidBumpResult)
+ // A fatal event without a failure reason will give an error.
+ b = BumpResult{
+ Event: TxFailed,
+ }
+ require.ErrorIs(t, b.Validate(), ErrInvalidBumpResult)
+
// A confirmed event without fee info will give an error.
b = BumpResult{
Tx: &wire.MsgTx{},
@@ -104,6 +110,20 @@ func TestBumpResultValidate(t *testing.T) {
Event: TxPublished,
}
require.NoError(t, b.Validate())
+
+ // Tx is allowed to be nil in a TxFailed event.
+ b = BumpResult{
+ Event: TxFailed,
+ Err: errDummy,
+ }
+ require.NoError(t, b.Validate())
+
+ // Tx is allowed to be nil in a TxFatal event.
+ b = BumpResult{
+ Event: TxFatal,
+ Err: errDummy,
+ }
+ require.NoError(t, b.Validate())
}
// TestCalcSweepTxWeight checks that the weight of the sweep tx is calculated
@@ -332,13 +352,10 @@ func TestStoreRecord(t *testing.T) {
}
// Call the method under test.
- requestID := tp.storeRecord(tx, req, feeFunc, fee, utxoIndex)
-
- // Check the request ID is as expected.
- require.Equal(t, initialCounter+1, requestID)
+ tp.storeRecord(initialCounter, tx, req, feeFunc, fee, utxoIndex)
// Read the saved record and compare.
- record, ok := tp.records.Load(requestID)
+ record, ok := tp.records.Load(initialCounter)
require.True(t, ok)
require.Equal(t, tx, record.tx)
require.Equal(t, feeFunc, record.feeFunction)
@@ -635,23 +652,19 @@ func TestCreateRBFCompliantTx(t *testing.T) {
},
}
+ var requestCounter atomic.Uint64
for _, tc := range testCases {
tc := tc
+ rid := requestCounter.Add(1)
t.Run(tc.name, func(t *testing.T) {
tc.setupMock()
// Call the method under test.
- id, err := tp.createRBFCompliantTx(req, m.feeFunc)
+ err := tp.createRBFCompliantTx(rid, 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)
- }
})
}
}
@@ -684,7 +697,8 @@ func TestTxPublisherBroadcast(t *testing.T) {
// Create a testing record and put it in the map.
fee := btcutil.Amount(1000)
- requestID := tp.storeRecord(tx, req, m.feeFunc, fee, utxoIndex)
+ requestID := uint64(1)
+ tp.storeRecord(requestID, tx, req, m.feeFunc, fee, utxoIndex)
// Quickly check when the requestID cannot be found, an error is
// returned.
@@ -779,6 +793,9 @@ func TestRemoveResult(t *testing.T) {
op: 0,
}
+ // Create a test request ID counter.
+ requestCounter := atomic.Uint64{}
+
testCases := []struct {
name string
setupRecord func() uint64
@@ -790,12 +807,13 @@ func TestRemoveResult(t *testing.T) {
// removed.
name: "remove on TxConfirmed",
setupRecord: func() uint64 {
- id := tp.storeRecord(
- tx, req, m.feeFunc, fee, utxoIndex,
+ rid := requestCounter.Add(1)
+ tp.storeRecord(
+ rid, tx, req, m.feeFunc, fee, utxoIndex,
)
- tp.subscriberChans.Store(id, nil)
+ tp.subscriberChans.Store(rid, nil)
- return id
+ return rid
},
result: &BumpResult{
Event: TxConfirmed,
@@ -807,12 +825,13 @@ func TestRemoveResult(t *testing.T) {
// 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, utxoIndex,
+ rid := requestCounter.Add(1)
+ tp.storeRecord(
+ rid, tx, req, m.feeFunc, fee, utxoIndex,
)
- tp.subscriberChans.Store(id, nil)
+ tp.subscriberChans.Store(rid, nil)
- return id
+ return rid
},
result: &BumpResult{
Event: TxFailed,
@@ -825,12 +844,13 @@ func TestRemoveResult(t *testing.T) {
// 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, utxoIndex,
+ rid := requestCounter.Add(1)
+ tp.storeRecord(
+ rid, tx, req, m.feeFunc, fee, utxoIndex,
)
- tp.subscriberChans.Store(id, nil)
+ tp.subscriberChans.Store(rid, nil)
- return id
+ return rid
},
result: &BumpResult{
Event: TxPublished,
@@ -885,7 +905,8 @@ func TestNotifyResult(t *testing.T) {
// Create a testing record and put it in the map.
fee := btcutil.Amount(1000)
- requestID := tp.storeRecord(tx, req, m.feeFunc, fee, utxoIndex)
+ requestID := uint64(1)
+ tp.storeRecord(requestID, tx, req, m.feeFunc, fee, utxoIndex)
// Create a subscription to the event.
subscriber := make(chan *BumpResult, 1)
@@ -933,41 +954,17 @@ func TestNotifyResult(t *testing.T) {
}
}
-// TestBroadcastSuccess checks the public `Broadcast` method can successfully
-// broadcast a tx based on the request.
-func TestBroadcastSuccess(t *testing.T) {
+// TestBroadcast checks the public `Broadcast` method can successfully register
+// a broadcast request.
+func TestBroadcast(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
- tp, m := createTestPublisher(t)
+ tp, _ := 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)
@@ -981,27 +978,24 @@ func TestBroadcastSuccess(t *testing.T) {
}
// 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)
- }
+ resultChan := tp.Broadcast(req)
+ require.NotNil(t, resultChan)
// Validate the record was stored.
require.Equal(t, 1, tp.records.Len())
require.Equal(t, 1, tp.subscriberChans.Len())
+
+ // Validate the record.
+ rid := tp.requestCounter.Load()
+ record, found := tp.records.Load(rid)
+ require.True(t, found)
+ require.Equal(t, req, record.req)
}
-// TestBroadcastFail checks the public `Broadcast` returns the error or a
-// failed result when the broadcast fails.
-func TestBroadcastFail(t *testing.T) {
+// TestBroadcastImmediate checks the public `Broadcast` method can successfully
+// register a broadcast request and publish the tx when `Immediate` flag is
+// set.
+func TestBroadcastImmediate(t *testing.T) {
t.Parallel()
// Create a publisher using the mocks.
@@ -1020,64 +1014,27 @@ func TestBroadcastFail(t *testing.T) {
Budget: btcutil.Amount(1000),
MaxFeeRate: feerate * 10,
DeadlineHeight: 10,
+ Immediate: true,
}
- // Mock the fee estimator to return the testing fee rate.
+ // Mock the fee estimator to return an error.
//
- // We are not testing `NewLinearFeeFunction` here, so the actual params
- // used are irrelevant.
+ // NOTE: We are not testing `handleInitialBroadcast` here, but only
+ // interested in checking that this method is indeed called when
+ // `Immediate` is true. Thus we mock the method to return an error to
+ // quickly abort. As long as this mocked method is called, we know the
+ // `Immediate` flag works.
m.estimator.On("EstimateFeePerKW", mock.Anything).Return(
- feerate, nil).Twice()
- m.estimator.On("RelayFeePerKW").Return(chainfee.FeePerKwFloor).Twice()
+ chainfee.SatPerKWeight(0), errDummy).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 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)
- }
+ // Send the req and expect no error.
+ resultChan := tp.Broadcast(req)
+ require.NotNil(t, resultChan)
- // Validate the record was removed.
- require.Equal(t, 0, tp.records.Len())
- require.Equal(t, 0, tp.subscriberChans.Len())
+ // Validate the record was removed due to an error returned in initial
+ // broadcast.
+ require.Empty(t, tp.records.Len())
+ require.Empty(t, tp.subscriberChans.Len())
}
// TestCreateAnPublishFail checks all the error cases are handled properly in
@@ -1250,7 +1207,8 @@ func TestHandleTxConfirmed(t *testing.T) {
// Create a testing record and put it in the map.
fee := btcutil.Amount(1000)
- requestID := tp.storeRecord(tx, req, m.feeFunc, fee, utxoIndex)
+ requestID := uint64(1)
+ tp.storeRecord(requestID, tx, req, m.feeFunc, fee, utxoIndex)
record, ok := tp.records.Load(requestID)
require.True(t, ok)
@@ -1330,7 +1288,8 @@ func TestHandleFeeBumpTx(t *testing.T) {
// Create a testing record and put it in the map.
fee := btcutil.Amount(1000)
- requestID := tp.storeRecord(tx, req, m.feeFunc, fee, utxoIndex)
+ requestID := uint64(1)
+ tp.storeRecord(requestID, tx, req, m.feeFunc, fee, utxoIndex)
// Create a subscription to the event.
subscriber := make(chan *BumpResult, 1)
@@ -1531,3 +1490,183 @@ func TestProcessRecords(t *testing.T) {
require.Equal(t, requestID2, result.requestID)
}
}
+
+// TestHandleInitialBroadcastSuccess checks `handleInitialBroadcast` method can
+// successfully broadcast a tx based on the request.
+func TestHandleInitialBroadcastSuccess(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,
+ }
+
+ // Register the testing record use `Broadcast`.
+ resultChan := tp.Broadcast(req)
+
+ // Grab the monitor record from the map.
+ rid := tp.requestCounter.Load()
+ rec, ok := tp.records.Load(rid)
+ require.True(t, ok)
+
+ // Call the method under test.
+ tp.wg.Add(1)
+ tp.handleInitialBroadcast(rec, rid)
+
+ // 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())
+}
+
+// TestHandleInitialBroadcastFail checks `handleInitialBroadcast` returns the
+// error or a failed result when the broadcast fails.
+func TestHandleInitialBroadcastFail(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()
+
+ // Register the testing record use `Broadcast`.
+ resultChan := tp.Broadcast(req)
+
+ // Grab the monitor record from the map.
+ rid := tp.requestCounter.Load()
+ rec, ok := tp.records.Load(rid)
+ require.True(t, ok)
+
+ // Call the method under test and expect an error returned.
+ tp.wg.Add(1)
+ tp.handleInitialBroadcast(rec, rid)
+
+ // 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 TxFatal.
+ require.Equal(t, TxFatal, result.Event)
+ }
+
+ // 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()
+
+ // Register the testing record use `Broadcast`.
+ resultChan = tp.Broadcast(req)
+
+ // Grab the monitor record from the map.
+ rid = tp.requestCounter.Load()
+ rec, ok = tp.records.Load(rid)
+ require.True(t, ok)
+
+ // Call the method under test.
+ tp.wg.Add(1)
+ tp.handleInitialBroadcast(rec, rid)
+
+ // 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())
+}
diff --git a/sweep/fee_function.go b/sweep/fee_function.go
index bff44000be2..eb2ed4d6b13 100644
--- a/sweep/fee_function.go
+++ b/sweep/fee_function.go
@@ -14,6 +14,9 @@ var (
// ErrMaxPosition is returned when trying to increase the position of
// the fee function while it's already at its max.
ErrMaxPosition = errors.New("position already at max")
+
+ // ErrZeroFeeRateDelta is returned when the fee rate delta is zero.
+ ErrZeroFeeRateDelta = errors.New("fee rate delta is zero")
)
// mSatPerKWeight represents a fee rate in msat/kw.
@@ -169,7 +172,7 @@ func NewLinearFeeFunction(maxFeeRate chainfee.SatPerKWeight,
"endingFeeRate=%v, width=%v, delta=%v", start, end,
l.width, l.deltaFeeRate)
- return nil, fmt.Errorf("fee rate delta is zero")
+ return nil, ErrZeroFeeRateDelta
}
// Attach the calculated values to the fee function.
diff --git a/sweep/mock_test.go b/sweep/mock_test.go
index eeeb2839693..f9471f22a0e 100644
--- a/sweep/mock_test.go
+++ b/sweep/mock_test.go
@@ -268,6 +268,13 @@ func (m *MockInputSet) StartingFeeRate() fn.Option[chainfee.SatPerKWeight] {
return args.Get(0).(fn.Option[chainfee.SatPerKWeight])
}
+// Immediate returns whether the inputs should be swept immediately.
+func (m *MockInputSet) Immediate() bool {
+ args := m.Called()
+
+ return args.Bool(0)
+}
+
// MockBumper is a mock implementation of the interface Bumper.
type MockBumper struct {
mock.Mock
@@ -277,14 +284,14 @@ type MockBumper struct {
var _ Bumper = (*MockBumper)(nil)
// Broadcast broadcasts the transaction to the network.
-func (m *MockBumper) Broadcast(req *BumpRequest) (<-chan *BumpResult, error) {
+func (m *MockBumper) Broadcast(req *BumpRequest) <-chan *BumpResult {
args := m.Called(req)
if args.Get(0) == nil {
- return nil, args.Error(1)
+ return nil
}
- return args.Get(0).(chan *BumpResult), args.Error(1)
+ return args.Get(0).(chan *BumpResult)
}
// MockFeeFunction is a mock implementation of the FeeFunction interface.
diff --git a/sweep/sweeper.go b/sweep/sweeper.go
index 9eeefc94b84..88404e52a83 100644
--- a/sweep/sweeper.go
+++ b/sweep/sweeper.go
@@ -10,6 +10,7 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/davecgh/go-spew/spew"
+ "github.com/lightningnetwork/lnd/chainio"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/input"
@@ -222,9 +223,52 @@ func (p *SweeperInput) terminated() bool {
}
}
+// isMature returns a boolean indicating whether the input has a timelock that
+// has been reached or not. The locktime found is also returned.
+func (p *SweeperInput) isMature(currentHeight uint32) (bool, uint32) {
+ locktime, _ := p.RequiredLockTime()
+ if currentHeight < locktime {
+ log.Debugf("Input %v has locktime=%v, current height is %v",
+ p, locktime, currentHeight)
+
+ return false, locktime
+ }
+
+ // If the input has a CSV that's not yet reached, we will skip
+ // this input and wait for the expiry.
+ //
+ // NOTE: We need to consider whether this input can be included in the
+ // next block or not, which means the CSV will be checked against the
+ // currentHeight plus one.
+ locktime = p.BlocksToMaturity() + p.HeightHint()
+ if currentHeight+1 < locktime {
+ log.Debugf("Input %v has CSV expiry=%v, current height is %v, "+
+ "skipped sweeping", p, locktime, currentHeight)
+
+ return false, locktime
+ }
+
+ return true, locktime
+}
+
// InputsMap is a type alias for a set of pending inputs.
type InputsMap = map[wire.OutPoint]*SweeperInput
+// inputsMapToString returns a human readable interpretation of the pending
+// inputs.
+func inputsMapToString(inputs InputsMap) string {
+ if len(inputs) == 0 {
+ return ""
+ }
+
+ inps := make([]input.Input, 0, len(inputs))
+ for _, in := range inputs {
+ inps = append(inps, in)
+ }
+
+ return "\n" + inputTypeSummary(inps)
+}
+
// pendingSweepsReq is an internal message we'll use to represent an external
// caller's intent to retrieve all of the pending inputs the UtxoSweeper is
// attempting to sweep.
@@ -280,6 +324,10 @@ type UtxoSweeper struct {
started uint32 // To be used atomically.
stopped uint32 // To be used atomically.
+ // Embed the blockbeat consumer struct to get access to the method
+ // `NotifyBlockProcessed` and the `BlockbeatChan`.
+ chainio.BeatConsumer
+
cfg *UtxoSweeperConfig
newInputs chan *sweepInputMessage
@@ -309,11 +357,14 @@ type UtxoSweeper struct {
// updated whenever a new block epoch is received.
currentHeight int32
- // bumpResultChan is a channel that receives broadcast results from the
+ // bumpRespChan is a channel that receives broadcast results from the
// TxPublisher.
- bumpResultChan chan *BumpResult
+ bumpRespChan chan *bumpResp
}
+// Compile-time check for the chainio.Consumer interface.
+var _ chainio.Consumer = (*UtxoSweeper)(nil)
+
// UtxoSweeperConfig contains dependencies of UtxoSweeper.
type UtxoSweeperConfig struct {
// GenSweepScript generates a P2WKH script belonging to the wallet where
@@ -387,7 +438,7 @@ type sweepInputMessage struct {
// New returns a new Sweeper instance.
func New(cfg *UtxoSweeperConfig) *UtxoSweeper {
- return &UtxoSweeper{
+ s := &UtxoSweeper{
cfg: cfg,
newInputs: make(chan *sweepInputMessage),
spendChan: make(chan *chainntnfs.SpendDetail),
@@ -395,12 +446,17 @@ func New(cfg *UtxoSweeperConfig) *UtxoSweeper {
pendingSweepsReqs: make(chan *pendingSweepsReq),
quit: make(chan struct{}),
inputs: make(InputsMap),
- bumpResultChan: make(chan *BumpResult, 100),
+ bumpRespChan: make(chan *bumpResp, 100),
}
+
+ // Mount the block consumer.
+ s.BeatConsumer = chainio.NewBeatConsumer(s.quit, s.Name())
+
+ return s
}
// Start starts the process of constructing and publish sweep txes.
-func (s *UtxoSweeper) Start() error {
+func (s *UtxoSweeper) Start(beat chainio.Blockbeat) error {
if !atomic.CompareAndSwapUint32(&s.started, 0, 1) {
return nil
}
@@ -411,49 +467,12 @@ func (s *UtxoSweeper) Start() error {
// not change from here on.
s.relayFeeRate = s.cfg.FeeEstimator.RelayFeePerKW()
- // We need to register for block epochs and retry sweeping every block.
- // We should get a notification with the current best block immediately
- // if we don't provide any epoch. We'll wait for that in the collector.
- blockEpochs, err := s.cfg.Notifier.RegisterBlockEpochNtfn(nil)
- if err != nil {
- return fmt.Errorf("register block epoch ntfn: %w", err)
- }
+ // Set the current height.
+ s.currentHeight = beat.Height()
// Start sweeper main loop.
s.wg.Add(1)
- go func() {
- defer blockEpochs.Cancel()
- defer s.wg.Done()
-
- s.collector(blockEpochs.Epochs)
-
- // The collector exited and won't longer handle incoming
- // requests. This can happen on shutdown, when the block
- // notifier shuts down before the sweeper and its clients. In
- // order to not deadlock the clients waiting for their requests
- // being handled, we handle them here and immediately return an
- // error. When the sweeper finally is shut down we can exit as
- // the clients will be notified.
- for {
- select {
- case inp := <-s.newInputs:
- inp.resultChan <- Result{
- Err: ErrSweeperShuttingDown,
- }
-
- case req := <-s.pendingSweepsReqs:
- req.errChan <- ErrSweeperShuttingDown
-
- case req := <-s.updateReqs:
- req.responseChan <- &updateResp{
- err: ErrSweeperShuttingDown,
- }
-
- case <-s.quit:
- return
- }
- }
- }()
+ go s.collector()
return nil
}
@@ -480,6 +499,11 @@ func (s *UtxoSweeper) Stop() error {
return nil
}
+// NOTE: part of the `chainio.Consumer` interface.
+func (s *UtxoSweeper) Name() string {
+ return "UtxoSweeper"
+}
+
// SweepInput sweeps inputs back into the wallet. The inputs will be batched and
// swept after the batch time window ends. A custom fee preference can be
// provided to determine what fee rate should be used for the input. Note that
@@ -502,7 +526,7 @@ func (s *UtxoSweeper) SweepInput(inp input.Input,
}
absoluteTimeLock, _ := inp.RequiredLockTime()
- log.Infof("Sweep request received: out_point=%v, witness_type=%v, "+
+ log.Debugf("Sweep request received: out_point=%v, witness_type=%v, "+
"relative_time_lock=%v, absolute_time_lock=%v, amount=%v, "+
"parent=(%v), params=(%v)", inp.OutPoint(), inp.WitnessType(),
inp.BlocksToMaturity(), absoluteTimeLock,
@@ -611,17 +635,8 @@ func (s *UtxoSweeper) removeConflictSweepDescendants(
// collector is the sweeper main loop. It processes new inputs, spend
// notifications and counts down to publication of the sweep tx.
-func (s *UtxoSweeper) collector(blockEpochs <-chan *chainntnfs.BlockEpoch) {
- // We registered for the block epochs with a nil request. The notifier
- // should send us the current best block immediately. So we need to wait
- // for it here because we need to know the current best height.
- select {
- case bestBlock := <-blockEpochs:
- s.currentHeight = bestBlock.Height
-
- case <-s.quit:
- return
- }
+func (s *UtxoSweeper) collector() {
+ defer s.wg.Done()
for {
// Clean inputs, which will remove inputs that are swept,
@@ -681,9 +696,9 @@ func (s *UtxoSweeper) collector(blockEpochs <-chan *chainntnfs.BlockEpoch) {
s.sweepPendingInputs(inputs)
}
- case result := <-s.bumpResultChan:
+ case resp := <-s.bumpRespChan:
// Handle the bump event.
- err := s.handleBumpEvent(result)
+ err := s.handleBumpEvent(resp)
if err != nil {
log.Errorf("Failed to handle bump event: %v",
err)
@@ -691,28 +706,26 @@ func (s *UtxoSweeper) collector(blockEpochs <-chan *chainntnfs.BlockEpoch) {
// A new block comes in, update the bestHeight, perform a check
// over all pending inputs and publish sweeping txns if needed.
- case epoch, ok := <-blockEpochs:
- if !ok {
- // We should stop the sweeper before stopping
- // the chain service. Otherwise it indicates an
- // error.
- log.Error("Block epoch channel closed")
-
- return
- }
-
+ case beat := <-s.BlockbeatChan:
// Update the sweeper to the best height.
- s.currentHeight = epoch.Height
+ s.currentHeight = beat.Height()
// Update the inputs with the latest height.
inputs := s.updateSweeperInputs()
log.Debugf("Received new block: height=%v, attempt "+
- "sweeping %d inputs", epoch.Height, len(inputs))
+ "sweeping %d inputs:%s", s.currentHeight,
+ len(inputs),
+ lnutils.NewLogClosure(func() string {
+ return inputsMapToString(inputs)
+ }))
// Attempt to sweep any pending inputs.
s.sweepPendingInputs(inputs)
+ // Notify we've processed the block.
+ s.NotifyBlockProcessed(beat, nil)
+
case <-s.quit:
return
}
@@ -827,6 +840,7 @@ func (s *UtxoSweeper) sweep(set InputSet) error {
DeliveryAddress: sweepAddr,
MaxFeeRate: s.cfg.MaxFeeRate.FeePerKWeight(),
StartingFeeRate: set.StartingFeeRate(),
+ Immediate: set.Immediate(),
// TODO(yy): pass the strategy here.
}
@@ -837,27 +851,13 @@ func (s *UtxoSweeper) sweep(set InputSet) error {
// Broadcast will return a read-only chan that we will listen to for
// this publish result and future RBF attempt.
- resp, err := s.cfg.Publisher.Broadcast(req)
- if err != nil {
- outpoints := make([]wire.OutPoint, len(set.Inputs()))
- for i, inp := range set.Inputs() {
- outpoints[i] = inp.OutPoint()
- }
-
- log.Errorf("Initial broadcast failed: %v, inputs=\n%v", err,
- inputTypeSummary(set.Inputs()))
-
- // TODO(yy): find out which input is causing the failure.
- s.markInputsPublishFailed(outpoints)
-
- return err
- }
+ resp := s.cfg.Publisher.Broadcast(req)
// Successfully sent the broadcast attempt, we now handle the result by
// subscribing to the result chan and listen for future updates about
// this tx.
s.wg.Add(1)
- go s.monitorFeeBumpResult(resp)
+ go s.monitorFeeBumpResult(set, resp)
return nil
}
@@ -867,14 +867,14 @@ func (s *UtxoSweeper) sweep(set InputSet) error {
func (s *UtxoSweeper) markInputsPendingPublish(set InputSet) {
// Reschedule sweep.
for _, input := range set.Inputs() {
- pi, ok := s.inputs[input.OutPoint()]
+ op := input.OutPoint()
+ pi, ok := s.inputs[op]
if !ok {
// It could be that this input is an additional wallet
// input that was attached. In that case there also
// isn't a pending input to update.
log.Tracef("Skipped marking input as pending "+
- "published: %v not found in pending inputs",
- input.OutPoint())
+ "published: %v not found in pending inputs", op)
continue
}
@@ -885,8 +885,7 @@ func (s *UtxoSweeper) markInputsPendingPublish(set InputSet) {
// publish.
if pi.terminated() {
log.Errorf("Expect input %v to not have terminated "+
- "state, instead it has %v",
- input.OutPoint, pi.state)
+ "state, instead it has %v", op, pi.state)
continue
}
@@ -901,9 +900,7 @@ func (s *UtxoSweeper) markInputsPendingPublish(set InputSet) {
// markInputsPublished updates the sweeping tx in db and marks the list of
// inputs as published.
-func (s *UtxoSweeper) markInputsPublished(tr *TxRecord,
- inputs []*wire.TxIn) error {
-
+func (s *UtxoSweeper) markInputsPublished(tr *TxRecord, set InputSet) error {
// Mark this tx in db once successfully published.
//
// NOTE: this will behave as an overwrite, which is fine as the record
@@ -915,15 +912,15 @@ func (s *UtxoSweeper) markInputsPublished(tr *TxRecord,
}
// Reschedule sweep.
- for _, input := range inputs {
- pi, ok := s.inputs[input.PreviousOutPoint]
+ for _, input := range set.Inputs() {
+ op := input.OutPoint()
+ pi, ok := s.inputs[op]
if !ok {
// It could be that this input is an additional wallet
// input that was attached. In that case there also
// isn't a pending input to update.
log.Tracef("Skipped marking input as published: %v "+
- "not found in pending inputs",
- input.PreviousOutPoint)
+ "not found in pending inputs", op)
continue
}
@@ -932,8 +929,7 @@ func (s *UtxoSweeper) markInputsPublished(tr *TxRecord,
if pi.state != PendingPublish {
// We may get a Published if this is a replacement tx.
log.Debugf("Expect input %v to have %v, instead it "+
- "has %v", input.PreviousOutPoint,
- PendingPublish, pi.state)
+ "has %v", op, PendingPublish, pi.state)
continue
}
@@ -949,9 +945,10 @@ func (s *UtxoSweeper) markInputsPublished(tr *TxRecord,
}
// markInputsPublishFailed marks the list of inputs as failed to be published.
-func (s *UtxoSweeper) markInputsPublishFailed(outpoints []wire.OutPoint) {
+func (s *UtxoSweeper) markInputsPublishFailed(set InputSet) {
// Reschedule sweep.
- for _, op := range outpoints {
+ for _, inp := range set.Inputs() {
+ op := inp.OutPoint()
pi, ok := s.inputs[op]
if !ok {
// It could be that this input is an additional wallet
@@ -1054,6 +1051,12 @@ func (s *UtxoSweeper) handlePendingSweepsReq(
resps := make(map[wire.OutPoint]*PendingInputResponse, len(s.inputs))
for _, inp := range s.inputs {
+ // Skip immature inputs for compatibility.
+ mature, _ := inp.isMature(uint32(s.currentHeight))
+ if !mature {
+ continue
+ }
+
// Only the exported fields are set, as we expect the response
// to only be consumed externally.
op := inp.OutPoint()
@@ -1189,17 +1192,34 @@ func (s *UtxoSweeper) mempoolLookup(op wire.OutPoint) fn.Option[wire.MsgTx] {
return s.cfg.Mempool.LookupInputMempoolSpend(op)
}
-// handleNewInput processes a new input by registering spend notification and
-// scheduling sweeping for it.
-func (s *UtxoSweeper) handleNewInput(input *sweepInputMessage) error {
+// calculateDefaultDeadline calculates the default deadline height for a sweep
+// request that has no deadline height specified.
+func (s *UtxoSweeper) calculateDefaultDeadline(pi *SweeperInput) int32 {
// Create a default deadline height, which will be used when there's no
// DeadlineHeight specified for a given input.
defaultDeadline := s.currentHeight + int32(s.cfg.NoDeadlineConfTarget)
+ // If the input is immature and has a locktime, we'll use the locktime
+ // height as the starting height.
+ matured, locktime := pi.isMature(uint32(s.currentHeight))
+ if !matured {
+ defaultDeadline = int32(locktime + s.cfg.NoDeadlineConfTarget)
+ log.Debugf("Input %v is immature, using locktime=%v instead "+
+ "of current height=%d as starting height",
+ pi.OutPoint(), locktime, s.currentHeight)
+ }
+
+ return defaultDeadline
+}
+
+// handleNewInput processes a new input by registering spend notification and
+// scheduling sweeping for it.
+func (s *UtxoSweeper) handleNewInput(input *sweepInputMessage) error {
outpoint := input.input.OutPoint()
pi, pending := s.inputs[outpoint]
if pending {
- log.Debugf("Already has pending input %v received", outpoint)
+ log.Infof("Already has pending input %v received, old params: "+
+ "%v, new params %v", outpoint, pi.params, input.params)
s.handleExistingInput(input, pi)
@@ -1220,15 +1240,22 @@ func (s *UtxoSweeper) handleNewInput(input *sweepInputMessage) error {
Input: input.input,
params: input.params,
rbf: rbfInfo,
- // Set the acutal deadline height.
- DeadlineHeight: input.params.DeadlineHeight.UnwrapOr(
- defaultDeadline,
- ),
}
+ // Set the acutal deadline height.
+ pi.DeadlineHeight = input.params.DeadlineHeight.UnwrapOr(
+ s.calculateDefaultDeadline(pi),
+ )
+
s.inputs[outpoint] = pi
log.Tracef("input %v, state=%v, added to inputs", outpoint, pi.state)
+ log.Infof("Registered sweep request at block %d: out_point=%v, "+
+ "witness_type=%v, amount=%v, deadline=%d, state=%v, "+
+ "params=(%v)", s.currentHeight, pi.OutPoint(), pi.WitnessType(),
+ btcutil.Amount(pi.SignDesc().Output.Value), pi.DeadlineHeight,
+ pi.state, pi.params)
+
// Start watching for spend of this input, either by us or the remote
// party.
cancel, err := s.monitorSpend(
@@ -1457,11 +1484,6 @@ func (s *UtxoSweeper) markInputFailed(pi *SweeperInput, err error) {
pi.state = Failed
- // Remove all other inputs in this exclusive group.
- if pi.params.ExclusiveGroup != nil {
- s.removeExclusiveGroup(*pi.params.ExclusiveGroup)
- }
-
s.signalResult(pi, Result{Err: err})
}
@@ -1479,6 +1501,8 @@ func (s *UtxoSweeper) updateSweeperInputs() InputsMap {
// turn this inputs map into a SyncMap in case we wanna add concurrent
// access to the map in the future.
for op, input := range s.inputs {
+ log.Tracef("Checking input: %s, state=%v", input, input.state)
+
// If the input has reached a final state, that it's either
// been swept, or failed, or excluded, we will remove it from
// our sweeper.
@@ -1506,23 +1530,8 @@ func (s *UtxoSweeper) updateSweeperInputs() InputsMap {
// If the input has a locktime that's not yet reached, we will
// skip this input and wait for the locktime to be reached.
- locktime, _ := input.RequiredLockTime()
- if uint32(s.currentHeight) < locktime {
- log.Warnf("Skipping input %v due to locktime=%v not "+
- "reached, current height is %v", op, locktime,
- s.currentHeight)
-
- continue
- }
-
- // If the input has a CSV that's not yet reached, we will skip
- // this input and wait for the expiry.
- locktime = input.BlocksToMaturity() + input.HeightHint()
- if s.currentHeight < int32(locktime)-1 {
- log.Infof("Skipping input %v due to CSV expiry=%v not "+
- "reached, current height is %v", op, locktime,
- s.currentHeight)
-
+ mature, _ := input.isMature(uint32(s.currentHeight))
+ if !mature {
continue
}
@@ -1539,6 +1548,8 @@ func (s *UtxoSweeper) updateSweeperInputs() InputsMap {
// sweepPendingInputs is called when the ticker fires. It will create clusters
// and attempt to create and publish the sweeping transactions.
func (s *UtxoSweeper) sweepPendingInputs(inputs InputsMap) {
+ log.Debugf("Sweeping %v inputs", len(inputs))
+
// Cluster all of our inputs based on the specific Aggregator.
sets := s.cfg.Aggregator.ClusterInputs(inputs)
@@ -1580,11 +1591,24 @@ func (s *UtxoSweeper) sweepPendingInputs(inputs InputsMap) {
}
}
+// bumpResp wraps the result of a bump attempt returned from the fee bumper and
+// the inputs being used.
+type bumpResp struct {
+ // result is the result of the bump attempt returned from the fee
+ // bumper.
+ result *BumpResult
+
+ // set is the input set that was used in the bump attempt.
+ set InputSet
+}
+
// monitorFeeBumpResult subscribes to the passed result chan to listen for
// future updates about the sweeping tx.
//
// NOTE: must run as a goroutine.
-func (s *UtxoSweeper) monitorFeeBumpResult(resultChan <-chan *BumpResult) {
+func (s *UtxoSweeper) monitorFeeBumpResult(set InputSet,
+ resultChan <-chan *BumpResult) {
+
defer s.wg.Done()
for {
@@ -1596,9 +1620,14 @@ func (s *UtxoSweeper) monitorFeeBumpResult(resultChan <-chan *BumpResult) {
continue
}
+ resp := &bumpResp{
+ result: r,
+ set: set,
+ }
+
// Send the result back to the main event loop.
select {
- case s.bumpResultChan <- r:
+ case s.bumpRespChan <- resp:
case <-s.quit:
log.Debug("Sweeper shutting down, skip " +
"sending bump result")
@@ -1613,6 +1642,14 @@ func (s *UtxoSweeper) monitorFeeBumpResult(resultChan <-chan *BumpResult) {
// in sweeper and rely solely on this event to mark
// inputs as Swept?
if r.Event == TxConfirmed || r.Event == TxFailed {
+ // Exit if the tx is failed to be created.
+ if r.Tx == nil {
+ log.Debugf("Received %v for nil tx, "+
+ "exit monitor", r.Event)
+
+ return
+ }
+
log.Debugf("Received %v for sweep tx %v, exit "+
"fee bump monitor", r.Event,
r.Tx.TxHash())
@@ -1634,25 +1671,28 @@ func (s *UtxoSweeper) monitorFeeBumpResult(resultChan <-chan *BumpResult) {
// handleBumpEventTxFailed handles the case where the tx has been failed to
// publish.
-func (s *UtxoSweeper) handleBumpEventTxFailed(r *BumpResult) error {
+func (s *UtxoSweeper) handleBumpEventTxFailed(resp *bumpResp) {
+ r := resp.result
tx, err := r.Tx, r.Err
- log.Errorf("Fee bump attempt failed for tx=%v: %v", tx.TxHash(), err)
-
- outpoints := make([]wire.OutPoint, 0, len(tx.TxIn))
- for _, inp := range tx.TxIn {
- outpoints = append(outpoints, inp.PreviousOutPoint)
+ if tx != nil {
+ log.Warnf("Fee bump attempt failed for tx=%v: %v", tx.TxHash(),
+ err)
}
+ // NOTE: When marking the inputs as failed, we are using the input set
+ // instead of the inputs found in the tx. This is fine for current
+ // version of the sweeper because we always create a tx using ALL of
+ // the inputs specified by the set.
+ //
// TODO(yy): should we also remove the failed tx from db?
- s.markInputsPublishFailed(outpoints)
-
- return err
+ s.markInputsPublishFailed(resp.set)
}
// handleBumpEventTxReplaced handles the case where the sweeping tx has been
// replaced by a new one.
-func (s *UtxoSweeper) handleBumpEventTxReplaced(r *BumpResult) error {
+func (s *UtxoSweeper) handleBumpEventTxReplaced(resp *bumpResp) error {
+ r := resp.result
oldTx := r.ReplacedTx
newTx := r.Tx
@@ -1692,12 +1732,13 @@ func (s *UtxoSweeper) handleBumpEventTxReplaced(r *BumpResult) error {
}
// Mark the inputs as published using the replacing tx.
- return s.markInputsPublished(tr, r.Tx.TxIn)
+ return s.markInputsPublished(tr, resp.set)
}
// handleBumpEventTxPublished handles the case where the sweeping tx has been
// successfully published.
-func (s *UtxoSweeper) handleBumpEventTxPublished(r *BumpResult) error {
+func (s *UtxoSweeper) handleBumpEventTxPublished(resp *bumpResp) error {
+ r := resp.result
tx := r.Tx
tr := &TxRecord{
Txid: tx.TxHash(),
@@ -1707,7 +1748,7 @@ func (s *UtxoSweeper) handleBumpEventTxPublished(r *BumpResult) error {
// Inputs have been successfully published so we update their
// states.
- err := s.markInputsPublished(tr, tx.TxIn)
+ err := s.markInputsPublished(tr, resp.set)
if err != nil {
return err
}
@@ -1723,15 +1764,71 @@ func (s *UtxoSweeper) handleBumpEventTxPublished(r *BumpResult) error {
return nil
}
+// handleBumpEventTxFatal handles the case where there's an unexpected error
+// when creating or publishing the sweeping tx. In this case, the tx will be
+// removed from the sweeper store and the inputs will be marked as `Failed`,
+// which means they will not be retried.
+func (s *UtxoSweeper) handleBumpEventTxFatal(resp *bumpResp) error {
+ r := resp.result
+
+ // Remove the tx from the sweeper store if there is one. Since this is
+ // a broadcast error, it's likely there isn't a tx here.
+ if r.Tx != nil {
+ txid := r.Tx.TxHash()
+ log.Infof("Tx=%v failed with unexpected error: %v", txid, r.Err)
+
+ // Remove the tx from the sweeper db if it exists.
+ if err := s.cfg.Store.DeleteTx(txid); err != nil {
+ return fmt.Errorf("delete tx record for %v: %w", txid,
+ err)
+ }
+ }
+
+ // Mark the inputs as failed.
+ s.markInputsFailed(resp.set, r.Err)
+
+ return nil
+}
+
+// markInputsFailed marks all inputs found in the tx as failed. It will also
+// notify all the subscribers of these inputs.
+func (s *UtxoSweeper) markInputsFailed(set InputSet, err error) {
+ for _, inp := range set.Inputs() {
+ outpoint := inp.OutPoint()
+
+ input, ok := s.inputs[outpoint]
+ if !ok {
+ // It's very likely that a spending tx contains inputs
+ // that we don't know.
+ log.Tracef("Skipped marking input as failed: %v not "+
+ "found in pending inputs", outpoint)
+
+ continue
+ }
+
+ // If the input is already in a terminal state, we don't want
+ // to rewrite it, which also indicates an error as we only get
+ // an error event during the initial broadcast.
+ if input.terminated() {
+ log.Errorf("Skipped marking input=%v as failed due to "+
+ "unexpected state=%v", outpoint, input.state)
+
+ continue
+ }
+
+ s.markInputFailed(input, err)
+ }
+}
+
// handleBumpEvent handles the result sent from the bumper based on its event
// type.
//
// NOTE: TxConfirmed event is not handled, since we already subscribe to the
// input's spending event, we don't need to do anything here.
-func (s *UtxoSweeper) handleBumpEvent(r *BumpResult) error {
- log.Debugf("Received bump event [%v] for tx %v", r.Event, r.Tx.TxHash())
+func (s *UtxoSweeper) handleBumpEvent(r *bumpResp) error {
+ log.Debugf("Received bump result %v", r.result)
- switch r.Event {
+ switch r.result.Event {
// The tx has been published, we update the inputs' state and create a
// record to be stored in the sweeper db.
case TxPublished:
@@ -1739,12 +1836,18 @@ func (s *UtxoSweeper) handleBumpEvent(r *BumpResult) error {
// The tx has failed, we update the inputs' state.
case TxFailed:
- return s.handleBumpEventTxFailed(r)
+ s.handleBumpEventTxFailed(r)
+ return nil
// The tx has been replaced, we will remove the old tx and replace it
// with the new one.
case TxReplaced:
return s.handleBumpEventTxReplaced(r)
+
+ // There's a fatal error in creating the tx, we will remove the tx from
+ // the sweeper db and mark the inputs as failed.
+ case TxFatal:
+ return s.handleBumpEventTxFatal(r)
}
return nil
diff --git a/sweep/sweeper_test.go b/sweep/sweeper_test.go
index 7d99ba93b9a..16a4a46fbe5 100644
--- a/sweep/sweeper_test.go
+++ b/sweep/sweeper_test.go
@@ -1,6 +1,7 @@
package sweep
import (
+ "crypto/rand"
"errors"
"testing"
"time"
@@ -12,6 +13,7 @@ import (
"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"
@@ -33,6 +35,41 @@ var (
})
)
+// 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) {
@@ -47,50 +84,21 @@ func TestMarkInputsPendingPublish(t *testing.T) {
set := &MockInputSet{}
defer set.AssertExpectations(t)
- // Create three testing inputs.
- //
- // 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})
-
- // inputInit specifies a newly created input.
- inputInit := &input.MockInput{}
- defer inputInit.AssertExpectations(t)
-
- inputInit.On("OutPoint").Return(wire.OutPoint{Index: 1})
-
- s.inputs[inputInit.OutPoint()] = &SweeperInput{
- state: Init,
- }
-
- // inputPendingPublish specifies an input that's about to be published.
- inputPendingPublish := &input.MockInput{}
- defer inputPendingPublish.AssertExpectations(t)
-
- inputPendingPublish.On("OutPoint").Return(wire.OutPoint{Index: 2})
-
- s.inputs[inputPendingPublish.OutPoint()] = &SweeperInput{
- state: PendingPublish,
- }
-
- // inputTerminated specifies an input that's terminated.
- inputTerminated := &input.MockInput{}
- defer inputTerminated.AssertExpectations(t)
-
- inputTerminated.On("OutPoint").Return(wire.OutPoint{Index: 3})
-
- s.inputs[inputTerminated.OutPoint()] = &SweeperInput{
- state: Excluded,
- }
+ // 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{
- inputNotExist, inputInit, inputPendingPublish, inputTerminated,
+ inputInit, inputPendingPublish, inputTerminated,
})
s.markInputsPendingPublish(set)
@@ -122,36 +130,22 @@ func TestMarkInputsPublished(t *testing.T) {
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 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. When marking this as
- // published, we should see an error log as this input hasn't been
- // published yet.
- inputInit := &wire.TxIn{
- PreviousOutPoint: wire.OutPoint{Index: 2},
- }
- s.inputs[inputInit.PreviousOutPoint] = &SweeperInput{
- state: Init,
- }
-
- // 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,
- }
+ // 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.
@@ -171,9 +165,9 @@ func TestMarkInputsPublished(t *testing.T) {
// 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.
- err = s.markInputsPublished(dummyTR, []*wire.TxIn{
- inputNotExist, inputInit, inputPendingPublish,
- })
+ set.On("Inputs").Return([]input.Input{inputInit, inputPendingPublish})
+
+ err = s.markInputsPublished(dummyTR, set)
require.NoError(err)
// We expect unchanged number of pending inputs.
@@ -181,11 +175,11 @@ func TestMarkInputsPublished(t *testing.T) {
// We expect the init input's state to stay unchanged.
require.Equal(Init,
- s.inputs[inputInit.PreviousOutPoint].state)
+ s.inputs[inputInit.OutPoint()].state)
// We expect the pending-publish input's is now marked as published.
require.Equal(Published,
- s.inputs[inputPendingPublish.PreviousOutPoint].state)
+ s.inputs[inputPendingPublish.OutPoint()].state)
// Assert mocked statements are executed as expected.
mockStore.AssertExpectations(t)
@@ -202,117 +196,75 @@ func TestMarkInputsPublishFailed(t *testing.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 testing inputs for each state.
- //
- // 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. When marking this as
- // published, we should see an error log as this input hasn't been
- // published yet.
- inputInit := &wire.TxIn{
- PreviousOutPoint: wire.OutPoint{Index: 2},
- }
- s.inputs[inputInit.PreviousOutPoint] = &SweeperInput{
- state: Init,
- }
-
- // 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,
- }
-
- // inputPublished specifies an input that's published.
- inputPublished := &wire.TxIn{
- PreviousOutPoint: wire.OutPoint{Index: 4},
- }
- s.inputs[inputPublished.PreviousOutPoint] = &SweeperInput{
- state: Published,
- }
-
- // inputPublishFailed specifies an input that's failed to be published.
- inputPublishFailed := &wire.TxIn{
- PreviousOutPoint: wire.OutPoint{Index: 5},
- }
- s.inputs[inputPublishFailed.PreviousOutPoint] = &SweeperInput{
- state: PublishFailed,
- }
-
- // inputSwept specifies an input that's swept.
- inputSwept := &wire.TxIn{
- PreviousOutPoint: wire.OutPoint{Index: 6},
- }
- s.inputs[inputSwept.PreviousOutPoint] = &SweeperInput{
- state: Swept,
- }
-
- // inputExcluded specifies an input that's excluded.
- inputExcluded := &wire.TxIn{
- PreviousOutPoint: wire.OutPoint{Index: 7},
- }
- s.inputs[inputExcluded.PreviousOutPoint] = &SweeperInput{
- state: Excluded,
- }
-
- // inputFailed specifies an input that's failed.
- inputFailed := &wire.TxIn{
- PreviousOutPoint: wire.OutPoint{Index: 8},
- }
- s.inputs[inputFailed.PreviousOutPoint] = &SweeperInput{
- state: Failed,
- }
+ // 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.
+ // - inputFailed specifies an input that's failed.
+ 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)
+ inputFailed = createMockInput(t, s, Failed)
+ )
- // Gather all inputs' outpoints.
- pendingOps := make([]wire.OutPoint, 0, len(s.inputs)+1)
- for op := range s.inputs {
- pendingOps = append(pendingOps, op)
- }
- pendingOps = append(pendingOps, inputNotExist.PreviousOutPoint)
+ // Gather all inputs.
+ set.On("Inputs").Return([]input.Input{
+ inputInit, inputPendingPublish, inputPublished,
+ inputPublishFailed, inputSwept, inputExcluded, inputFailed,
+ })
// 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(pendingOps)
+ 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.PreviousOutPoint].state)
+ s.inputs[inputInit.OutPoint()].state)
// We expect the pending-publish input's is now marked as publish
// failed.
require.Equal(PublishFailed,
- s.inputs[inputPendingPublish.PreviousOutPoint].state)
+ s.inputs[inputPendingPublish.OutPoint()].state)
// We expect the published input's is now marked as publish failed.
require.Equal(PublishFailed,
- s.inputs[inputPublished.PreviousOutPoint].state)
+ s.inputs[inputPublished.OutPoint()].state)
// We expect the publish failed input to stay unchanged.
require.Equal(PublishFailed,
- s.inputs[inputPublishFailed.PreviousOutPoint].state)
+ s.inputs[inputPublishFailed.OutPoint()].state)
// We expect the swept input to stay unchanged.
- require.Equal(Swept, s.inputs[inputSwept.PreviousOutPoint].state)
+ require.Equal(Swept, s.inputs[inputSwept.OutPoint()].state)
// We expect the excluded input to stay unchanged.
- require.Equal(Excluded, s.inputs[inputExcluded.PreviousOutPoint].state)
+ require.Equal(Excluded, s.inputs[inputExcluded.OutPoint()].state)
// We expect the failed input to stay unchanged.
- require.Equal(Failed, s.inputs[inputFailed.PreviousOutPoint].state)
+ require.Equal(Failed, s.inputs[inputFailed.OutPoint()].state)
// Assert mocked statements are executed as expected.
mockStore.AssertExpectations(t)
@@ -491,6 +443,7 @@ func TestUpdateSweeperInputs(t *testing.T) {
// 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
@@ -499,6 +452,7 @@ func TestUpdateSweeperInputs(t *testing.T) {
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
@@ -704,11 +658,13 @@ func TestSweepPendingInputs(t *testing.T) {
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.
@@ -719,13 +675,8 @@ func TestSweepPendingInputs(t *testing.T) {
setNeedWallet, normalSet,
})
- // Mock `Broadcast` to return an error. This should cause the
- // `createSweepTx` inside `sweep` to fail. This is done so we can
- // terminate the method early as we are only interested in testing the
- // workflow in `sweepPendingInputs`. We don't need to test `sweep` here
- // as it should be tested in its own unit test.
- dummyErr := errors.New("dummy error")
- publisher.On("Broadcast", mock.Anything).Return(nil, dummyErr).Twice()
+ // Mock `Broadcast` to return a result.
+ publisher.On("Broadcast", mock.Anything).Return(nil).Twice()
// Call the method under test.
s.sweepPendingInputs(pis)
@@ -736,33 +687,33 @@ func TestSweepPendingInputs(t *testing.T) {
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{})
- var (
- // Create four testing outpoints.
- op1 = wire.OutPoint{Hash: chainhash.Hash{1}}
- op2 = wire.OutPoint{Hash: chainhash.Hash{2}}
- op3 = wire.OutPoint{Hash: chainhash.Hash{3}}
- opNotExist = wire.OutPoint{Hash: chainhash.Hash{4}}
- )
+ // 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.
- input1 := &input.MockInput{}
- defer input1.AssertExpectations(t)
-
- input2 := &input.MockInput{}
- defer input2.AssertExpectations(t)
+ var (
+ input1 = createMockInput(t, s, PendingPublish)
+ input2 = createMockInput(t, s, PendingPublish)
+ input3 = createMockInput(t, s, PendingPublish)
+ )
- input3 := &input.MockInput{}
- defer input3.AssertExpectations(t)
+ op1 := input1.OutPoint()
+ op2 := input2.OutPoint()
+ op3 := input3.OutPoint()
// Construct the initial state for the sweeper.
- s.inputs = InputsMap{
- op1: &SweeperInput{Input: input1, state: PendingPublish},
- op2: &SweeperInput{Input: input2, state: PendingPublish},
- op3: &SweeperInput{Input: input3, state: PendingPublish},
- }
+ set.On("Inputs").Return([]input.Input{input1, input2, input3})
// Create a testing tx that spends the first two inputs.
tx := &wire.MsgTx{
@@ -780,16 +731,26 @@ func TestHandleBumpEventTxFailed(t *testing.T) {
Err: errDummy,
}
+ // Create a testing bump response.
+ resp := &bumpResp{
+ result: br,
+ set: set,
+ }
+
// Call the method under test.
- err := s.handleBumpEvent(br)
- require.ErrorIs(t, err, errDummy)
+ 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 is not updated.
- require.Equal(t, PendingPublish, s.inputs[op3].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)
@@ -808,23 +769,21 @@ func TestHandleBumpEventTxReplaced(t *testing.T) {
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 testing outpoint.
- op := wire.OutPoint{Hash: chainhash.Hash{1}}
-
// Create a mock input.
- inp := &input.MockInput{}
- defer inp.AssertExpectations(t)
+ inp := createMockInput(t, s, PendingPublish)
+ set.On("Inputs").Return([]input.Input{inp})
- // Construct the initial state for the sweeper.
- s.inputs = InputsMap{
- op: &SweeperInput{Input: inp, state: PendingPublish},
- }
+ op := inp.OutPoint()
// Create a testing tx that spends the input.
tx := &wire.MsgTx{
@@ -849,12 +808,18 @@ func TestHandleBumpEventTxReplaced(t *testing.T) {
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(br)
+ err := s.handleBumpEventTxReplaced(resp)
require.ErrorIs(t, err, dummyErr)
// Mock the store to return the old tx record.
@@ -869,7 +834,7 @@ func TestHandleBumpEventTxReplaced(t *testing.T) {
store.On("DeleteTx", tx.TxHash()).Return(dummyErr).Once()
// Call the method under test and assert the error is returned.
- err = s.handleBumpEventTxReplaced(br)
+ err = s.handleBumpEventTxReplaced(resp)
require.ErrorIs(t, err, dummyErr)
// Mock the store to return the old tx record and delete it without
@@ -889,7 +854,7 @@ func TestHandleBumpEventTxReplaced(t *testing.T) {
wallet.On("CancelRebroadcast", tx.TxHash()).Once()
// Call the method under test.
- err = s.handleBumpEventTxReplaced(br)
+ err = s.handleBumpEventTxReplaced(resp)
require.NoError(t, err)
// Assert the state of the input is updated.
@@ -905,22 +870,20 @@ func TestHandleBumpEventTxPublished(t *testing.T) {
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 testing outpoint.
- op := wire.OutPoint{Hash: chainhash.Hash{1}}
-
// Create a mock input.
- inp := &input.MockInput{}
- defer inp.AssertExpectations(t)
+ inp := createMockInput(t, s, PendingPublish)
+ set.On("Inputs").Return([]input.Input{inp})
- // Construct the initial state for the sweeper.
- s.inputs = InputsMap{
- op: &SweeperInput{Input: inp, state: PendingPublish},
- }
+ op := inp.OutPoint()
// Create a testing tx that spends the input.
tx := &wire.MsgTx{
@@ -936,6 +899,12 @@ func TestHandleBumpEventTxPublished(t *testing.T) {
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(),
@@ -943,7 +912,7 @@ func TestHandleBumpEventTxPublished(t *testing.T) {
}).Return(nil).Once()
// Call the method under test.
- err := s.handleBumpEventTxPublished(br)
+ err := s.handleBumpEventTxPublished(resp)
require.NoError(t, err)
// Assert the state of the input is updated.
@@ -961,25 +930,21 @@ func TestMonitorFeeBumpResult(t *testing.T) {
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 testing outpoint.
- op := wire.OutPoint{Hash: chainhash.Hash{1}}
-
// Create a mock input.
- inp := &input.MockInput{}
- defer inp.AssertExpectations(t)
-
- // Construct the initial state for the sweeper.
- s.inputs = InputsMap{
- op: &SweeperInput{Input: inp, state: PendingPublish},
- }
+ inp := createMockInput(t, s, PendingPublish)
// Create a testing tx that spends the input.
+ op := inp.OutPoint()
tx := &wire.MsgTx{
LockTime: 1,
TxIn: []*wire.TxIn{
@@ -1058,7 +1023,8 @@ func TestMonitorFeeBumpResult(t *testing.T) {
return resultChan
},
shouldExit: false,
- }, {
+ },
+ {
// When the sweeper is shutting down, the monitor loop
// should exit.
name: "exit on sweeper shutdown",
@@ -1085,7 +1051,7 @@ func TestMonitorFeeBumpResult(t *testing.T) {
s.wg.Add(1)
go func() {
- s.monitorFeeBumpResult(resultChan)
+ s.monitorFeeBumpResult(set, resultChan)
close(done)
}()
@@ -1111,3 +1077,125 @@ func TestMonitorFeeBumpResult(t *testing.T) {
})
}
}
+
+// 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.
+ // - inputFailed specifies an input that's failed.
+ 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)
+ inputFailed = createMockInput(t, s, Failed)
+ )
+
+ // Gather all inputs.
+ set.On("Inputs").Return([]input.Input{
+ inputInit, inputPendingPublish, inputPublished,
+ inputPublishFailed, inputSwept, inputExcluded, inputFailed,
+ })
+
+ // Mark the test inputs. We expect the non-exist input and
+ // inputSwept/inputExcluded/inputFailed to be skipped.
+ s.markInputsFailed(set, errDummy)
+
+ // We expect unchanged number of pending inputs.
+ require.Len(s.inputs, 7)
+
+ // We expect the init input's to be marked as failed.
+ require.Equal(Failed, s.inputs[inputInit.OutPoint()].state)
+
+ // We expect the pending-publish input to be marked as failed.
+ require.Equal(Failed, s.inputs[inputPendingPublish.OutPoint()].state)
+
+ // We expect the published input to be marked as failed.
+ require.Equal(Failed, s.inputs[inputPublished.OutPoint()].state)
+
+ // We expect the publish failed input to be markd as failed.
+ require.Equal(Failed, 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(Failed, s.inputs[inputFailed.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)
+}
diff --git a/sweep/tx_input_set.go b/sweep/tx_input_set.go
index adae7cf131e..b80d52b0ea2 100644
--- a/sweep/tx_input_set.go
+++ b/sweep/tx_input_set.go
@@ -64,6 +64,13 @@ type InputSet interface {
// StartingFeeRate returns the max starting fee rate found in the
// inputs.
StartingFeeRate() fn.Option[chainfee.SatPerKWeight]
+
+ // Immediate returns a boolean to indicate whether the tx made from
+ // this input set should be published immediately.
+ //
+ // TODO(yy): create a new method `Params` to combine the informational
+ // methods DeadlineHeight, Budget, StartingFeeRate and Immediate.
+ Immediate() bool
}
// createWalletTxInput converts a wallet utxo into an object that can be added
@@ -414,3 +421,18 @@ func (b *BudgetInputSet) StartingFeeRate() fn.Option[chainfee.SatPerKWeight] {
return startingFeeRate
}
+
+// Immediate returns whether the inputs should be swept immediately.
+//
+// NOTE: part of the InputSet interface.
+func (b *BudgetInputSet) Immediate() bool {
+ for _, inp := range b.inputs {
+ // As long as one of the inputs is immediate, the whole set is
+ // immediate.
+ if inp.params.Immediate {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/sweep/txgenerator.go b/sweep/txgenerator.go
index 993ee9e59dd..995949b15aa 100644
--- a/sweep/txgenerator.go
+++ b/sweep/txgenerator.go
@@ -315,5 +315,6 @@ func inputTypeSummary(inputs []input.Input) string {
part := fmt.Sprintf("%v (%v)", i.OutPoint(), i.WitnessType())
parts = append(parts, part)
}
- return strings.Join(parts, ", ")
+
+ return strings.Join(parts, "\n")
}
diff --git a/sweep/txgenerator_test.go b/sweep/txgenerator_test.go
index 71477bd6ec0..3f2051646a4 100644
--- a/sweep/txgenerator_test.go
+++ b/sweep/txgenerator_test.go
@@ -16,10 +16,12 @@ var (
input.HtlcOfferedRemoteTimeout,
input.WitnessKeyHash,
}
- expectedWeight = int64(1460)
- expectedSummary = "0000000000000000000000000000000000000000000000000000000000000000:10 (CommitmentTimeLock), " +
- "0000000000000000000000000000000000000000000000000000000000000001:11 (HtlcAcceptedSuccessSecondLevel), " +
- "0000000000000000000000000000000000000000000000000000000000000002:12 (HtlcOfferedRemoteTimeout), " +
+ expectedWeight = int64(1460)
+
+ //nolint:ll
+ expectedSummary = "0000000000000000000000000000000000000000000000000000000000000000:10 (CommitmentTimeLock)\n" +
+ "0000000000000000000000000000000000000000000000000000000000000001:11 (HtlcAcceptedSuccessSecondLevel)\n" +
+ "0000000000000000000000000000000000000000000000000000000000000002:12 (HtlcOfferedRemoteTimeout)\n" +
"0000000000000000000000000000000000000000000000000000000000000003:13 (WitnessKeyHash)"
)