diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index c3fc10d87..8516b5fbe 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 c57859ca4..8275886a1 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.