Skip to content

btcwbackend: reorg-aware chain notifier forwarding (C0b) - #559

Closed
ellemouton wants to merge 1 commit into
reorg-safe-chainsourcefrom
c0b-btcwbackend-reorg-aware
Closed

btcwbackend: reorg-aware chain notifier forwarding (C0b)#559
ellemouton wants to merge 1 commit into
reorg-safe-chainsourcefrom
c0b-btcwbackend-reorg-aware

Conversation

@ellemouton

Copy link
Copy Markdown
Member

Task 1b for the reorg-safety epic (darepo#454) — Neutrino-backed sibling of
#461 (lwwallet/Esplora).

neutrino's chainntnfs adapter already drives the full
Confirmed / NegativeConf / Done (and Spend / Reorg / Done) lifecycle
natively from its compact-block-filter scanner, but btcwbackend's
RegisterConf and RegisterSpend previously only read the first
Confirmed / Spend event and let the rest fall on the floor. The result:
any downstream consumer subscribed to a chainsource.ConfRegistration.Reorged
or .Done channel silently saw nothing on the neutrino backend, defeating
the whole reorg-safety substrate (#422).

This PR mirrors what chainbackends/lnd.go already does for the LND
backend:

  • Persistent select loop that forwards Confirmed/Spend,
    NegativeConf/Reorg, and Done to the matching chainsource channel.
  • Closed-channel arms nil'd out so the select doesn't busy-loop after
    one upstream sub-channel tears down.
  • LIFO defer order: event.CancelnotifyCtx cancel → close
    downstream chans, so the upstream notifier stops writing before the
    outgoing channels close.
  • Field type widened from *neutrinonotify.NeutrinoNotifier to the
    chainntnfs.ChainNotifier interface so a stub notifier can drive the
    lifecycle in tests without standing up a real neutrino chain service.

The reorg depth carried on NegativeConf is intentionally dropped — same
choice the LND backend makes — so both backends present the same wire
shape to chainsource consumers.

Stack

#422 (chainsource substrate) → this PR. Sibling of #461 (lwwallet).

Test plan

  • go vet ./...
  • make unit pkg=btcwbackend
    (TestRegisterConfForwardsReorgAndDone,
    TestRegisterSpendForwardsReorgAndDone,
    plus pre-existing TestSubmitPackage*)
  • CI integration tests

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors ChainBackend to use the chainntnfs.ChainNotifier interface instead of a concrete type, and updates RegisterConf and RegisterSpend to forward reorg and done lifecycle events to backend-agnostic channels. It also introduces comprehensive unit tests to verify this new forwarding behavior. The feedback highlights a potential data race and code smell in both registration methods where fields of the external event struct are mutated directly to disable select cases; using local channel variables is recommended instead.

Comment on lines +430 to 482
for {
select {
case lndConf, ok := <-event.Confirmed:
if !ok {
return
}

conf := &chainsource.TxConfirmation{
BlockHash: lndConf.BlockHash,
BlockHeight: lndConf.BlockHeight,
TxIndex: lndConf.TxIndex,
Tx: lndConf.Tx,
Block: lndConf.Block,
}
conf := &chainsource.TxConfirmation{
BlockHash: lndConf.BlockHash,
BlockHeight: lndConf.BlockHeight,
TxIndex: lndConf.TxIndex,
Tx: lndConf.Tx,
Block: lndConf.Block,
}

select {
case confChan <- conf:
case <-notifyCtx.Done():
return
}

case _, ok := <-event.NegativeConf:
if !ok {
event.NegativeConf = nil

continue
}

select {
case reorgChan <- struct{}{}:
case <-notifyCtx.Done():
return
}

case _, ok := <-event.Done:
if !ok {
event.Done = nil

continue
}

select {
case doneChan <- struct{}{}:
case <-notifyCtx.Done():
return
}

return

select {
case confChan <- conf:
case <-notifyCtx.Done():
return
}

case <-notifyCtx.Done():
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Mutating the fields of the event struct (e.g., event.NegativeConf = nil and event.Done = nil) directly is a code smell and a potential data race. The event struct is returned by an external package (chainntnfs), and mutating its fields concurrently from a background goroutine can race with other goroutines reading those fields (such as the notifier itself, or test assertions/mocks). Instead, use local variables for the channels within the select loop to safely nil them out and disable the select cases.

		confirmedChan := event.Confirmed
		negativeConfChan := event.NegativeConf
		doneEventChan := event.Done

		for {
			select {
			case lndConf, ok := <-confirmedChan:
				if !ok {
					return
				}

				conf := &chainsource.TxConfirmation{
					BlockHash:   lndConf.BlockHash,
					BlockHeight: lndConf.BlockHeight,
					TxIndex:     lndConf.TxIndex,
					Tx:          lndConf.Tx,
					Block:       lndConf.Block,
				}

				select {
				case confChan <- conf:
				case <-notifyCtx.Done():
					return
				}

			case _, ok := <-negativeConfChan:
				if !ok {
					negativeConfChan = nil

					continue
				}

				select {
				case reorgChan <- struct{}{}:
				case <-notifyCtx.Done():
					return
				}

			case _, ok := <-doneEventChan:
				if !ok {
					doneEventChan = nil

					continue
				}

				select {
				case doneChan <- struct{}{}:
				case <-notifyCtx.Done():
					return
				}

				return

			case <-notifyCtx.Done():
				return
			}
		}

Comment on lines +542 to 594
for {
select {
case lndSpend, ok := <-event.Spend:
if !ok {
return
}

spend := &chainsource.SpendDetail{
SpentOutPoint: lndSpend.SpentOutPoint,
SpenderTxHash: lndSpend.SpenderTxHash,
SpendingTx: lndSpend.SpendingTx,
SpenderInputIndex: lndSpend.SpenderInputIndex,
SpendingHeight: lndSpend.SpendingHeight,
}
spend := &chainsource.SpendDetail{
SpentOutPoint: lndSpend.SpentOutPoint,
SpenderTxHash: lndSpend.SpenderTxHash,
SpendingTx: lndSpend.SpendingTx,
SpenderInputIndex: lndSpend.SpenderInputIndex,
SpendingHeight: lndSpend.SpendingHeight,
}

select {
case spendChan <- spend:
case <-notifyCtx.Done():
return
}

case _, ok := <-event.Reorg:
if !ok {
event.Reorg = nil

continue
}

select {
case reorgChan <- struct{}{}:
case <-notifyCtx.Done():
return
}

case _, ok := <-event.Done:
if !ok {
event.Done = nil

continue
}

select {
case doneChan <- struct{}{}:
case <-notifyCtx.Done():
return
}

return

select {
case spendChan <- spend:
case <-notifyCtx.Done():
return
}

case <-notifyCtx.Done():
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Mutating the fields of the event struct (e.g., event.Reorg = nil and event.Done = nil) directly is a code smell and a potential data race. The event struct is returned by an external package (chainntnfs), and mutating its fields concurrently from a background goroutine can race with other goroutines reading those fields (such as the notifier itself, or test assertions/mocks). Instead, use local variables for the channels within the select loop to safely nil them out and disable the select cases.

		spendEventChan := event.Spend
		reorgEventChan := event.Reorg
		doneEventChan := event.Done

		for {
			select {
			case lndSpend, ok := <-spendEventChan:
				if !ok {
					return
				}

				spend := &chainsource.SpendDetail{
					SpentOutPoint:     lndSpend.SpentOutPoint,
					SpenderTxHash:     lndSpend.SpenderTxHash,
					SpendingTx:        lndSpend.SpendingTx,
					SpenderInputIndex: lndSpend.SpenderInputIndex,
					SpendingHeight:    lndSpend.SpendingHeight,
				}

				select {
				case spendChan <- spend:
				case <-notifyCtx.Done():
					return
				}

			case _, ok := <-reorgEventChan:
				if !ok {
					reorgEventChan = nil

					continue
				}

				select {
				case reorgChan <- struct{}{}:
				case <-notifyCtx.Done():
					return
				}

			case _, ok := <-doneEventChan:
				if !ok {
					doneEventChan = nil

					continue
				}

				select {
				case doneChan <- struct{}{}:
				case <-notifyCtx.Done():
					return
				}

				return

			case <-notifyCtx.Done():
				return
			}
		}

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the ChainBackend to support reorg-aware lifecycles for transaction confirmations and spends by abstracting the notifier to the chainntnfs.ChainNotifier interface and forwarding reorg and done signals. It also adds comprehensive unit tests in chain_backend_reorg_test.go. The feedback identifies potential data races in both RegisterConf and RegisterSpend where fields of the shared event struct are modified concurrently inside goroutines; copying these channels to local variables before the loop is recommended to safely disable the select cases.

Comment on lines 417 to 483
go func() {
// Defers run in LIFO order. event.Cancel() must run first
// so the upstream notifier stops writing to its internal
// channels before we cancel notifyCtx (which any in-flight
// downstream sends are still using) and finally close the
// outgoing chans. Reversing this order would race the
// upstream notifier against closed channels.
defer close(confChan)
defer close(reorgChan)
defer close(doneChan)
defer cancel()
defer safeCancel()

select {
case lndConf, ok := <-event.Confirmed:
if !ok {
return
}
for {
select {
case lndConf, ok := <-event.Confirmed:
if !ok {
return
}

conf := &chainsource.TxConfirmation{
BlockHash: lndConf.BlockHash,
BlockHeight: lndConf.BlockHeight,
TxIndex: lndConf.TxIndex,
Tx: lndConf.Tx,
Block: lndConf.Block,
}
conf := &chainsource.TxConfirmation{
BlockHash: lndConf.BlockHash,
BlockHeight: lndConf.BlockHeight,
TxIndex: lndConf.TxIndex,
Tx: lndConf.Tx,
Block: lndConf.Block,
}

select {
case confChan <- conf:
case <-notifyCtx.Done():
return
}

case _, ok := <-event.NegativeConf:
if !ok {
event.NegativeConf = nil

continue
}

select {
case reorgChan <- struct{}{}:
case <-notifyCtx.Done():
return
}

case _, ok := <-event.Done:
if !ok {
event.Done = nil

continue
}

select {
case doneChan <- struct{}{}:
case <-notifyCtx.Done():
return
}

return

select {
case confChan <- conf:
case <-notifyCtx.Done():
return
}

case <-notifyCtx.Done():
return
}
}()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Modifying the fields of the shared event struct (event.NegativeConf = nil and event.Done = nil) inside the concurrent forwarding goroutine can lead to data races if the struct is accessed or inspected elsewhere.

Instead, copy the channels to local variables before the loop and nil those out to safely disable the select cases.

	go func() {
		// Defers run in LIFO order. event.Cancel() must run first
		// so the upstream notifier stops writing to its internal
		// channels before we cancel notifyCtx (which any in-flight
		// downstream sends are still using) and finally close the
		// outgoing chans. Reversing this order would race the
		// upstream notifier against closed channels.
		defer close(confChan)
		defer close(reorgChan)
		defer close(doneChan)
		defer cancel()
		defer safeCancel()

		upstreamConfirmed := event.Confirmed
		upstreamNegConf := event.NegativeConf
		upstreamDone := event.Done

		for {
			select {
			case lndConf, ok := <-upstreamConfirmed:
				if !ok {
					return
				}

				conf := &chainsource.TxConfirmation{
					BlockHash:   lndConf.BlockHash,
					BlockHeight: lndConf.BlockHeight,
					TxIndex:     lndConf.TxIndex,
					Tx:          lndConf.Tx,
					Block:       lndConf.Block,
				}

				select {
				case confChan <- conf:
				case <-notifyCtx.Done():
					return
				}

			case _, ok := <-upstreamNegConf:
				if !ok {
					upstreamNegConf = nil

					continue
				}

				select {
				case reorgChan <- struct{}{}:
				case <-notifyCtx.Done():
					return
				}

			case _, ok := <-upstreamDone:
				if !ok {
					upstreamDone = nil

					continue
				}

				select {
				case doneChan <- struct{}{}:
				case <-notifyCtx.Done():
					return
				}

				return

			case <-notifyCtx.Done():
				return
			}
		}
	}()

Comment on lines 532 to 595
go func() {
// LIFO defer: event.Cancel() first so the upstream notifier
// stops writing, then cancel notifyCtx so in-flight downstream
// sends unblock, and finally close the outgoing chans.
defer close(spendChan)
defer close(reorgChan)
defer close(doneChan)
defer cancel()
defer safeCancel()

select {
case lndSpend, ok := <-event.Spend:
if !ok {
return
}
for {
select {
case lndSpend, ok := <-event.Spend:
if !ok {
return
}

spend := &chainsource.SpendDetail{
SpentOutPoint: lndSpend.SpentOutPoint,
SpenderTxHash: lndSpend.SpenderTxHash,
SpendingTx: lndSpend.SpendingTx,
SpenderInputIndex: lndSpend.SpenderInputIndex,
SpendingHeight: lndSpend.SpendingHeight,
}
spend := &chainsource.SpendDetail{
SpentOutPoint: lndSpend.SpentOutPoint,
SpenderTxHash: lndSpend.SpenderTxHash,
SpendingTx: lndSpend.SpendingTx,
SpenderInputIndex: lndSpend.SpenderInputIndex,
SpendingHeight: lndSpend.SpendingHeight,
}

select {
case spendChan <- spend:
case <-notifyCtx.Done():
return
}

case _, ok := <-event.Reorg:
if !ok {
event.Reorg = nil

continue
}

select {
case reorgChan <- struct{}{}:
case <-notifyCtx.Done():
return
}

case _, ok := <-event.Done:
if !ok {
event.Done = nil

continue
}

select {
case doneChan <- struct{}{}:
case <-notifyCtx.Done():
return
}

return

select {
case spendChan <- spend:
case <-notifyCtx.Done():
return
}

case <-notifyCtx.Done():
return
}
}()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Modifying the fields of the shared event struct (event.Reorg = nil and event.Done = nil) inside the concurrent forwarding goroutine can lead to data races if the struct is accessed or inspected elsewhere.

Instead, copy the channels to local variables before the loop and nil those out to safely disable the select cases.

	go func() {
		// LIFO defer: event.Cancel() first so the upstream notifier
		// stops writing, then cancel notifyCtx so in-flight downstream
		// sends unblock, and finally close the outgoing chans.
		defer close(spendChan)
		defer close(reorgChan)
		defer close(doneChan)
		defer cancel()
		defer safeCancel()

		upstreamSpend := event.Spend
		upstreamReorg := event.Reorg
		upstreamDone := event.Done

		for {
			select {
			case lndSpend, ok := <-upstreamSpend:
				if !ok {
					return
				}

				spend := &chainsource.SpendDetail{
					SpentOutPoint:     lndSpend.SpentOutPoint,
					SpenderTxHash:     lndSpend.SpenderTxHash,
					SpendingTx:        lndSpend.SpendingTx,
					SpenderInputIndex: lndSpend.SpenderInputIndex,
					SpendingHeight:    lndSpend.SpendingHeight,
				}

				select {
				case spendChan <- spend:
				case <-notifyCtx.Done():
					return
				}

			case _, ok := <-upstreamReorg:
				if !ok {
					upstreamReorg = nil

					continue
				}

				select {
				case reorgChan <- struct{}{}:
				case <-notifyCtx.Done():
					return
				}

			case _, ok := <-upstreamDone:
				if !ok {
					upstreamDone = nil

					continue
				}

				select {
				case doneChan <- struct{}{}:
				case <-notifyCtx.Done():
					return
				}

				return

			case <-notifyCtx.Done():
				return
			}
		}
	}()

@levmi levmi added the P1 Priority 1 — high label Jun 10, 2026
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch 3 times, most recently from ca29548 to a9ee5e8 Compare June 23, 2026 17:15
@ellemouton
ellemouton force-pushed the c0b-btcwbackend-reorg-aware branch from c88a093 to 71bb642 Compare June 24, 2026 22:16
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch 2 times, most recently from c86210e to 1ab3019 Compare June 29, 2026 15:05
@ellemouton
ellemouton force-pushed the c0b-btcwbackend-reorg-aware branch from 71bb642 to eda2540 Compare June 29, 2026 15:28
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch from 3eb6f61 to 449b6cf Compare July 1, 2026 16:09
@ellemouton
ellemouton force-pushed the c0b-btcwbackend-reorg-aware branch from eda2540 to 791c6ae Compare July 1, 2026 16:23
@levmi levmi added reorg safety Fund-safety: stuck, lost, or mis-counted funds labels Jul 6, 2026
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch 2 times, most recently from 32871de to 4a1f9b1 Compare July 8, 2026 20:41
Squashed for the btcd v2 port; forwards Neutrino chain-notifier reorg
signals into the reorg-aware chainsource lifecycle.
@ellemouton

Copy link
Copy Markdown
Member Author

Superseded by #895 as part of condensing the reorg-safety client stack (epic lightninglabs/darepo#454) from 12 PRs into 3. The commits are carried over unchanged; see #895. Branch retained as a backup.

@ellemouton ellemouton closed this Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P1 Priority 1 — high reorg safety Fund-safety: stuck, lost, or mis-counted funds

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants