Conversation
🟠 PR Severity: HIGH
🟠 High (2 files)
🟢 Low (1 file)
Analysis
To override, add a |
In this commit, we let duplicate confirmation subscriptions extend an existing historical scan when a later subscriber supplies an earlier height hint. The notifier scans only the uncovered prefix and waits for all in-flight scans before advancing the shared cache. We lower the persisted hint before starting a supplemental scan so a restart cannot discard the newly accepted range. Neutrino preserves the bounded prefix while still extending initial scans to the latest tip.
Height hints are only valid while a live notification owns scan progress. The previous notifier left cached hints behind after the final subscriber canceled, so a restart after a reorg could skip a confirmation or spend on the replacement chain. Pending callbacks and Neutrino progress could also recreate a purged hint. In this commit, we align request and reorg-index lifetime with in-flight scans, exclude subscriberless sets from hint updates, serialize Neutrino progress writes with cancellation, and retain the first script-only spend. Focused tests cover restart, reorg, callback, relevant-transaction, and progress races.
The existing properties exercise registration order within one confirmation request. They do not vary independent request state across cancellation, reorg, and restart boundaries. In this commit, we add a state-machine model with three requests and three subscriber slots per request. The oracle checks exact progress updates, confirmation and negative-confirmation delivery, duplicates, cancellation, connected and disconnected blocks, and notifier restarts. This model exposed the stale height-hint ownership fixed in the preceding commit.
7629ed0 to
223ab0f
Compare
|
Rebased. Pushed the property tests a bit harder and found some other lingering edge cases. The bulk of the diff is now extra tests. |
ViktorT-11
left a comment
There was a problem hiding this comment.
Generally the fixes introduced by this PR looks good from my end 🔥! Adding a few feedback comments below, where especially the first comment is feedback which I think might make sense to address (perhaps in a follow-up PR).
| // on the chain, the confirmation details must be provided with the | ||
| // UpdateConfDetails method, otherwise we will wait for the transaction/output | ||
| // script to confirm even though it already has. | ||
| func (n *TxNotifier) RegisterConf(txid *chainhash.Hash, pkScript []byte, |
There was a problem hiding this comment.
It looks like the issue we're fixing here in this commit for "Confirmation subscribers", where a second subscriber registering with an earlier hint doesn't result in an actual scan, is also present for "Spend subscribers" below, i.e. via the RegisterSpend API endpoint IIUC.
I.e. a second "Spend subscriber" won't result in a scan if it's hint is earlier than the initial subscriber.
In theory, if future code for some reason introduces a second subscriber, or an external subscriber calls that endpoint with the current chain tip on a restart, this could lead to lnd missing a force-close after the restart if that second subscriber is registered first.
Does it make sense to fix that issue for "Spend subscribers" in this PR as well? Or should we tackle that in a follow-up PR?
| } | ||
| } | ||
|
|
||
| func testConfirmationRegistrationOrder(t *testing.T, completeBeforeEarly, |
There was a problem hiding this comment.
nit: This applies to quite a bit of the test functions in this PR, but IMO I think the docs here is quite lacking, and we'd improve readability if the docs were expanded.
For example this function would benefit from some godocs despite being un-exported as it's quite a big function, as well as from more comments within the function which explains what that part of the function is doing (that applies to quite many test functions within this PR).
| // The cache is an optimization, so a write failure | ||
| // does not prevent the live notifier from scanning the | ||
| // prefix. | ||
| Log.Debugf( |
There was a problem hiding this comment.
nit: Should this really be a debug log level only? Perhaps a warn level is warranted, given that the message also details that this error doesn't prevent the live notifier from scanning the prefix.
| // Another scan is still checking an earlier range. An empty | ||
| // result cannot advance the shared height hint until all scans | ||
| // finish. | ||
| if confSet.pendingRescans > 0 { |
There was a problem hiding this comment.
Blocker (IMO): a failed historical rescan permanently pins this counter above zero.
All three backends bail out of the dispatch goroutine on scan error without calling UpdateConfDetails (bitcoindnotify/bitcoind.go:272, btcdnotify/btcd.go:387, neutrinonotify/neutrino.go:456 — the btcd one even carries a TODO(wilmer): add retry logic if rescan fails?). So pendingRescans never unwinds.
Before this PR that just meant "one scan stuck pending". Now it is considerably worse, because this guard and the one at L1015 make the counter authoritative for the whole set:
- the other, successful scan's result is swallowed here and
rescanStatusnever reachesrescanComplete; - the persisted hint was already lowered to the earlier subscriber's value at registration (L789);
unconfirmedRequests()skips non-rescanCompletesets, so nothing ever raises it again.
Net effect: a request that was healthy (rescanComplete, hint tracking tip) can be permanently downgraded to a frozen, very low persisted hint purely because a second subscriber registered with an earlier hint and that prefix scan happened to fail. Every subsequent restart then redoes the full rescan, forever.
The backends need to report dispatch failure back to the notifier (decrement the counter and restore the previous hint), not just log and return.
| // Otherwise, a restart before this prefix completes would | ||
| // retain the later cached hint and lose the earlier | ||
| // subscriber's range. | ||
| err := n.confirmHintCache.CommitConfirmHint( |
There was a problem hiding this comment.
If this CommitConfirmHint fails we log and carry on, but confSet.rescanStartHeight is lowered unconditionally a few lines down (L840) while the persisted hint keeps the higher value.
The live notifier is fine, but the comment right above says persisting first exists precisely so "a restart before this prefix completes would retain the later cached hint and lose the earlier subscriber's range" — which is exactly what happens on this path. A write failure here silently degrades back to the bug this commit fixes.
Suggest either not lowering rescanStartHeight when the persist fails (so the set stays consistent with what's on disk), or treating it as a hard failure for the supplemental dispatch. Either way this is more than a debug log.
| return nil, err | ||
| } | ||
|
|
||
| n.Lock() |
There was a problem hiding this comment.
Moving the lock above the hint-cache query closes a real TOCTOU against updateHints, but it also pulls kvdb I/O inside the notifier lock, which is on the block-processing path (ConnectTip/NotifyHeight):
QueryConfirmHint(read tx) now runs under the lock for every registration;- the supplemental path at L789 does a
CommitConfirmHintunder it; CancelConf/CancelSpendnow doPurge{Confirm,Spend}Hintunder it, and those arekvdb.Batch→ a bolt read-write transaction that can wait up toMaxBatchDelaybefore committing.
Two concerns:
- Latency — every cancel is now a synchronous disk write serialized against block processing.
- Reentrancy — bolt's write lock is not reentrant, so any caller that registers or cancels from inside an open
kvdb.Updateon the same DB would now self-deadlock. PreviouslyRegisterConfnever wrote the hint cache, so this hazard is new. I spot-checked the call sites incontractcourt/*andpeer/brontide.go:4947and they look clean, but I didn't trace every closure — worth an explicit audit.
The purge in particular looks like it could happen outside the lock.
| // Their hint is still purged so a restart cannot trust progress that no | ||
| // active subscriber owns. | ||
| if confSet.details != nil || confSet.pendingRescans > 0 { | ||
| n.purgeConfirmHint(confRequest) |
There was a problem hiding this comment.
Purging the hint when the last subscriber goes away is the right call for correctness, but it's worth being explicit about the cost: after this, re-registering the same request rescans from the caller's original (possibly very old) hint rather than resuming.
For subsystems that cancel and re-register the same request during normal operation (contract resolvers, chain watcher), that could turn into repeated multi-thousand-block rescans. Would it be viable to keep the hint but mark it unowned — i.e. usable as a floor but not trusted as proof of scan progress — instead of deleting it outright?
Not blocking, but I think it deserves a sentence in the commit message either way.
| case rescanPending: | ||
| // The pending scan covers this subscriber's complete historical | ||
| // range. Its result will be shared with this notification. | ||
| if earlierHint && ntfn.HeightHint < confSet.rescanStartHeight { |
There was a problem hiding this comment.
Minor asymmetry with the rescanComplete break at L709, which additionally requires confSet.details == nil before taking the supplemental path.
I couldn't construct a reachable rescanPending && details != nil state (the tip handler sets both together, and L1026 sets rescanComplete alongside the details), so I don't think this is a live bug — but the two guards should probably read the same so a future change can't diverge them.
| // reorgs. There is no subscriber whose dispatch would add this | ||
| // index. | ||
| height := details.BlockHeight | ||
| reorgSafeHeight := height + n.reorgSafetyLimit |
There was a problem hiding this comment.
When the confirmation is already deeper than reorgSafetyLimit, this skips the index insert, so the set is cached with details but never indexed — and ConnectTip's maturity pruner only walks confsByInitialHeight. The subscriberless set then stays in confNotifications for the lifetime of the process.
master leaked these too (it never deleted sets on cancel at all), so this isn't a regression — but since this commit is specifically about aligning set lifetime with ownership, it seems worth closing here rather than leaving one path that still never gets collected.
| spendSet.details = details | ||
| if len(spendSet.ntfns) == 0 { | ||
| spendHeight := uint32(details.SpendingHeight) | ||
| txSet, exists := n.spendsByHeight[spendHeight] |
There was a problem hiding this comment.
This index insert is missing the reorgSafeHeight > n.currentHeight guard that both of its siblings have — the conf-side equivalent at L1050 and dispatchSpendDetails at L1640.
Without it, a historical scan that lands a spend older than reorgSafetyLimit inserts into a spendsByHeight bucket whose maturity height has already passed. ConnectTip never sweeps it again and DisconnectTip only walks the height being disconnected, so both the spendsByHeight entry and the spendNotifications entry stay for the lifetime of the process.
Looks like a straightforward oversight — same guard as the other two should do it.
| // ownership boundary as the hint purge performed by cancellation. | ||
| var scanMtx sync.Mutex | ||
| var scanCanceled bool | ||
| originalCancel := ntfn.Event.Cancel |
There was a problem hiding this comment.
ntfn.Event.Cancel is reassigned here after RegisterSpend has already inserted the event into the TxNotifier's internal map, and without holding the notifier lock. That's an unsynchronized write to a field of an object that is already reachable from shared state.
It's benign today — I grepped and nothing inside chainntnfs calls Event.Cancel, which is why -race stays quiet — but it's a race the moment anything on the notifier side ever does, and that's a non-obvious invariant for a future change to preserve.
Cheapest fix is to build the wrapper before calling RegisterSpend, or to carry the cancellation flag on the registration struct instead of patching the published event.
| scanMtx.Lock() | ||
| defer scanMtx.Unlock() | ||
|
|
||
| scanCanceled = true |
There was a problem hiding this comment.
This holds scanMtx across originalCancel(), which goes CancelSpend → n.Lock() → PurgeSpendHint (a bolt RW transaction).
There's a pre-existing hazard where ConnectTip can block holding n.Lock() on a client that isn't draining its Event.Spend channel. If that same client is the one calling Cancel(), it now blocks holding scanMtx, which in turn wedges the neutrino historical-rescan goroutine in commitSpendHintIfActive. The client/ConnectTip interaction isn't new, but dragging the rescan goroutine into it is.
An atomic bool (or a try-lock on the progress write) would give the same ownership boundary without holding a mutex across a lock acquisition and a disk write.
| // registration. Extend that scan through any blocks connected while the | ||
| // filter update was in flight. A supplemental scan ends below that | ||
| // height and must retain its non-overlapping prefix boundary. | ||
| if ntfn.HistoricalDispatch.EndHeight == ntfn.Height { |
There was a problem hiding this comment.
This infers "initial scan" from an equality that can hold accidentally rather than from anything the TxNotifier actually states.
If a set previously took the startHeight > currentHeight path, rescanStartHeight is set to currentHeight + 1, so a later supplemental dispatch gets EndHeight == currentHeight == ntfn.Height and is treated as initial (extended to the neutrino tip). It happens to be harmless in that specific case — nothing had been scanned, so extending is correct — but it's an implicit coupling between two files that is easy to break.
An explicit Supplemental bool (or similar) on HistoricalConfDispatch would make the contract legible from both sides.
In this PR, we make confirmation notifications independent of the order in which subscribers register height hints.
Root Cause
The
TxNotifiercoalesces confirmation subscriptions for the same transaction and output script into a single historical scan. Before this change, the first registration selected that scan's lower bound. A later subscriber with an earlier height hint reused the first result without scanning the uncovered prefix.This became reachable for channel closes after #10331 added a second close-transaction confirmation subscriber that waits for the capacity-scaled confirmation depth, including six confirmations for larger channels. After a restart, the wallet rebroadcaster can register its six-confirmation subscription at the current tip before the chain watcher restores its subscription with the transaction's actual height. If the close transaction was mined below the first hint, the shared scan missed it. Backends without an independent transaction index could not recover the confirmation.
Follow-up state-machine property testing found a second height-hint ownership issue. The final subscriber could cancel after its scan advanced the cached hint, then a reorg and restart could reuse that stale hint and skip a confirmation or spend on the replacement chain. Pending callbacks and Neutrino scan progress could also restore a hint after cancellation.
Changes
Test Plan
go test ./chainntnfs -run 'TestConfirmation(RegistrationOrder|EqualHeightHintsShareScan)$' -count=20go test -race ./chainntnfs -run 'TestConfirmation(RegistrationOrder|EqualHeightHintsShareScan)$' -count=10go test ./chainntnfs -run '^TestConfirmationRegistrationOrderProperty$' -rapid.checks=1000 -count=1go test -race ./chainntnfs -run '^TestConfirmationRegistrationOrderProperty$' -rapid.checks=200 -count=1go test ./chainntnfs/... -count=1go test ./chainntnfs -run '^TestTxNotifierModelProperty$' -rapid.checks=10000 -count=1go test -race ./chainntnfs -run 'TestTxNotifier(Model|Stale|Canceled|Relevant|Repeated)' -rapid.checks=1000 -count=1go test -race ./chainntnfs/neutrinonotify -run '^TestCommitSpendHintIfActive$' -count=1make itest-only icase=open_psbt_channel_with_unstable_utxos backend=neutrino timeout=15mmake itest-only icase=open_channel_with_unstable_utxos backend=neutrino timeout=15mmake itest-only icase=channel_backup-restore_leased_zero_conf backend=neutrino timeout=15mmake itest-only icase=channel_backup-restore_leased backend=neutrino timeout=15mmake lintgo vet ./chainntnfs/...go test ./contractcourt -run '^$'Fixes #11182