contractcourt: add sync dispatch fast-path for single confirmation closes
What changed, and why it matters
This commit adds a special fast-path in LND's channel close watcher. When only one confirmation is needed (which only happens in integration tests), it immediately processes a channel close instead of waiting for the normal multi-confirmation safety check. This is described as a test-only optimization to fix flaky tests, not a production security fix. In normal production use, the code still waits for multiple confirmations before treating a channel close as final, preserving protection against blockchain reorganizations.
No immediate action required. Treat as a test reliability optimization rather than a security vulnerability. If reviewing for security, verify that requiredConfsForSpend() cannot return 1 in production builds and that the fast-path is not reachable outside controlled test configurations. Consider adding an explicit build-tag or runtime guard to ensure the fast-path cannot activate in production.
Security signals we found
Bypass of async confirmation state machine under single-confirmation configuration
Synchronous dispatch of channel close events upon spend detection when numConfs == 1
Change of error handling from return to continue in closeObserver event loop
Test expectation changed to remove confirmation registration wait
Commit message frames change as test-only optimization with production safety preserved
Evidence from the diff
The change introduces handleSpendDispatch() in contractcourt/chain_watcher.go. When requiredConfsForSpend() returns 1, it calls handleCommitSpend() synchronously and skips the async confirmation registration state machine. This fast-path is inserted into both the blockbeat and spend notification detection paths. The commit also changes two error paths from ‘return’ to ‘continue’ and defers spendNtfn.Cancel() via a closure. The test file is updated to remove expectations of confirmation registration when chanCloseConfs=1. The commit message explicitly states this is for integration tests (itests use build tag numConfs=1) and that production uses numConfs >= 3 with full async reorg protection.
Changed components
contractcourt/chain_watcher.gocontractcourt/chain_watcher_test.gocloseObserver goroutinehandleCommitSpend dispatch pathInspect captured patch +69 / −11
diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go
index c3fc10d..8516b5f 100644
--- a/contractcourt/chain_watcher.go
+++ b/contractcourt/chain_watcher.go
@@ -705,6 +705,12 @@ func newChainSet(chanState *channeldb.OpenChannel) (*chainSet, error) {
// - Pending (confNtfn != nil): Spend detected, waiting for N confirmations
//
// - Confirmed: Spend confirmed with N blocks, close has been processed
+//
+// For single-confirmation scenarios (numConfs == 1), we bypass the async state
+// machine and immediately dispatch close events upon spend detection. This
+// provides synchronous behavior for integration tests which expect immediate
+// notifications. For multi-confirmation scenarios (production with numConfs
+// >= 3), we use the full async state machine with reorg protection.
func (c *chainWatcher) closeObserver() {
defer c.wg.Done()
@@ -724,7 +730,7 @@ func (c *chainWatcher) closeObserver() {
}
spendNtfn := c.fundingSpendNtfn
- defer spendNtfn.Cancel()
+ defer func() { spendNtfn.Cancel() }()
// We use these variables to implement a state machine to track the
// state of the spend confirmation process:
@@ -756,6 +762,7 @@ func (c *chainWatcher) closeObserver() {
"duplicate spend detection for tx %v",
c.cfg.chanState.FundingOutpoint,
spend.SpenderTxHash)
+
return confNtfn, nil
}
@@ -815,7 +822,22 @@ func (c *chainWatcher) closeObserver() {
continue
}
+ // FAST PATH: Check if we should dispatch immediately
+ // for single-confirmation scenarios.
+ if c.handleSpendDispatch(spend, "blockbeat") {
+ if confNtfn != nil {
+ confNtfn.Cancel()
+ confNtfn = nil
+ }
+ pendingSpend = nil
+ continue
+ }
+
+ // ASYNC PATH: Multiple confirmations (production).
// STATE TRANSITION: None -> Pending (from blockbeat).
+ // We've detected a spend, but don't process it yet.
+ // Instead, register for confirmations to protect
+ // against shallow reorgs.
log.Infof("ChannelPoint(%v): detected spend from "+
"blockbeat, transitioning to %v",
c.cfg.chanState.FundingOutpoint,
@@ -825,7 +847,7 @@ func (c *chainWatcher) closeObserver() {
if err != nil {
log.Errorf("Unable to handle spend "+
"detection: %v", err)
- return
+ continue
}
pendingSpend = spend
confNtfn = newConfNtfn
@@ -838,6 +860,18 @@ func (c *chainWatcher) closeObserver() {
return
}
+ // FAST PATH: Check if we should dispatch immediately
+ // for single-confirmation scenarios.
+ if c.handleSpendDispatch(spend, "spend notification") {
+ if confNtfn != nil {
+ confNtfn.Cancel()
+ confNtfn = nil
+ }
+ pendingSpend = nil
+ continue
+ }
+
+ // ASYNC PATH: Multiple confirmations (production).
log.Infof("ChannelPoint(%v): detected spend from "+
"notification, transitioning to %v",
c.cfg.chanState.FundingOutpoint,
@@ -847,7 +881,7 @@ func (c *chainWatcher) closeObserver() {
if err != nil {
log.Errorf("Unable to handle spend "+
"detection: %v", err)
- return
+ continue
}
pendingSpend = spend
confNtfn = newConfNtfn
@@ -906,6 +940,8 @@ func (c *chainWatcher) closeObserver() {
return
}
+ c.fundingSpendNtfn = spendNtfn
+
log.Infof("ChannelPoint(%v): re-registered for spend "+
"detection", c.cfg.chanState.FundingOutpoint)
@@ -1675,6 +1711,30 @@ func deriveFundingPkScript(chanState *channeldb.OpenChannel) ([]byte, error) {
return fundingPkScript, nil
}
+// handleSpendDispatch processes a detected spend. For single-confirmation
+// scenarios (numConfs == 1), it immediately dispatches the close event and
+// returns true. For multi-confirmation scenarios, it returns false, indicating
+// the caller should proceed with the async state machine.
+func (c *chainWatcher) handleSpendDispatch(spend *chainntnfs.SpendDetail,
+ source string) bool {
+
+ numConfs := c.requiredConfsForSpend()
+ if numConfs == 1 {
+ log.Infof("ChannelPoint(%v): single confirmation mode, "+
+ "dispatching immediately from %s",
+ c.cfg.chanState.FundingOutpoint, source)
+
+ err := c.handleCommitSpend(spend)
+ if err != nil {
+ log.Errorf("Failed to handle commit spend: %v", err)
+ }
+
+ return true
+ }
+
+ return false
+}
+
// handleCommitSpend takes a spending tx of the funding output and handles the
// channel close based on the closure type.
func (c *chainWatcher) handleCommitSpend(
diff --git a/contractcourt/chain_watcher_test.go b/contractcourt/chain_watcher_test.go
index c57859c..8275886 100644
--- a/contractcourt/chain_watcher_test.go
+++ b/contractcourt/chain_watcher_test.go
@@ -94,10 +94,9 @@ func TestChainWatcherRemoteUnilateralClose(t *testing.T) {
t.Fatalf("unable to send blockbeat")
}
- // Wait for the chain watcher to register for confirmations and send
- // the confirmation. Since we set chanCloseConfs to 1, one confirmation
- // is sufficient.
- aliceNotifier.WaitForConfRegistrationAndSend(t)
+ // With chanCloseConfs set to 1, the fast-path dispatches immediately
+ // without confirmation registration. The close event should arrive
+ // directly after processing the blockbeat.
// We should get a new spend event over the remote unilateral close
// event channel.
@@ -231,10 +230,9 @@ func TestChainWatcherRemoteUnilateralClosePendingCommit(t *testing.T) {
t.Fatalf("unable to send blockbeat")
}
- // Wait for the chain watcher to register for confirmations and send
- // the confirmation. Since we set chanCloseConfs to 1, one confirmation
- // is sufficient.
- aliceNotifier.WaitForConfRegistrationAndSend(t)
+ // With chanCloseConfs set to 1, the fast-path dispatches immediately
+ // without confirmation registration. The close event should arrive
+ // directly after processing the blockbeat.
// We should get a new spend event over the remote unilateral close
// event channel.
Why this scored 25/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.