Skip to content

multi: Reorg-aware chainsource + chainbackends + txconfirm - #422

Closed
ellemouton wants to merge 1 commit into
mainfrom
reorg-safe-chainsource
Closed

multi: Reorg-aware chainsource + chainbackends + txconfirm#422
ellemouton wants to merge 1 commit into
mainfrom
reorg-safe-chainsource

Conversation

@ellemouton

@ellemouton ellemouton commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

Foundation layer for reorg-safe chain observation. This PR makes the chain-event surface reversible end to end so downstream subsystems can observe a tx or spend moving through:

Observed -> Reorged -> Observed -> Done

For txconfirm, the equivalent lifecycle is:

TxConfirmed -> TxReorged -> TxConfirmed -> TxFinalized

The important semantic change is that the first positive observation is no longer terminal. A confirmation or spend can be reported, later leave the best chain, then be reported again. This is required for later client and server work where Ark may use a chain fact at low confirmation depth, likely one confirmation, while still remaining recoverable if that fact reorgs out.

What Done / Finalized Means

Done and TxFinalized mean policy finality at the configured FinalityDepth, not absolute Bitcoin finality. They are not a claim that the tx can never be reorged under consensus.

The model is:

  • usability_depth: when a higher layer is willing to act on a chain fact. For fast Ark UX this may be 1 confirmation.
  • FinalityDepth: when this observation layer considers the watch complete enough to emit Done / TxFinalized. The production default wired here is chainsource.DefaultFinalityDepth (6).

So yes, we still care about reorgs after a one-confirmation observation. That is the main point of this PR: between first observation and policy finality, the watch stays alive and can emit Reorged, then a later positive observation again. Once Done / TxFinalized fires, low-level watchers may release resources. Later PRs will keep long-lived batch/VTXO meaning above this layer and can still reconcile or park on deeper-than-policy reorgs for still-relevant lineage.

Why This PR Exists Before Batch/VTXO Reorg Safety

This PR intentionally does not decide whether a VTXO, batch, round, OOR receive, or unroll source is usable. It only observes chain facts.

Later PRs will add a BatchCanonicalityManager above chainsource that interprets these raw observations:

  • batch tx confirms, reorgs out, reconfirms, or reaches policy finality;
  • batch input outpoints are spent by conflicting txs;
  • conflict spends reorg out or reach policy finality;
  • effective batch expiry is recomputed when a batch reconfirms at a different height.

That later layer will own VTXO lineage states such as available_provisional, limbo_reorg, limbo_conflict, and invalidated. This PR is the observation substrate those states need.

chainsource

  • Conf/spend watch registrations now expose Reorged and Done channels alongside the existing Confirmed/Spend channel.
  • New events: ConfReorgedEvent, ConfDoneEvent, SpendReorgedEvent, SpendDoneEvent.
  • ConfActor and SpendActor are multi-shot: Confirmed -> Reorged -> Confirmed -> Done instead of terminate-after-first-event.
  • Done is synthesized from configurable FinalityDepth height watermarks so backends that drop upstream Done signals, notably lndclient over gRPC, still complete the lifecycle.
  • RegisterBlocks for finality synthesis is retried with bounded backoff before falling back to backend Done.

chainbackends

  • LNDBackend forwards lnd reorg and finality notifications into the chainsource event surface.
  • lndclient adapter wires WithReOrgChan so the underlying gRPC stream is not torn down after the first event.

txconfirm

  • Confirmation watches are reorg-aware end to end.
  • TxConfirmed is no longer terminal.
  • New TxReorged and TxFinalized events.
  • New Finalized terminal state.
  • Reversible notifications (TxConfirmed, TxReorged, cached-confirmation replay) are fire-and-forget on bounded per-subscriber goroutines.
  • Terminal notifications (TxFinalized, TxFailed) keep the reliable goroutine + timeout + idempotent-retry pattern.
  • A backend Done for an entry not currently confirmed is logged and dropped rather than incorrectly promoting to Finalized.

darepod

  • Wires chainsource.DefaultFinalityDepth (6) into production ChainSourceConfig.
  • This is required for lndclient-backed transports where finality synthesis is the practical Done source.

Follow-Up Stack

This PR is the bottom of the reorg-safety stack.

Next PR (#410) adds unroll recovery-tx reorg handling:

  • rollback of unroll proof/sweep anchors on reorg;
  • provisional external-spend handling;
  • restart reconciliation against the canonical chain;
  • sweep-finality gating of PhaseCompleted.

Later PRs will add the batch/VTXO canonicality layer:

  • long-lived batch tx and batch input watching;
  • VTXO lineage availability and admission gates;
  • round-born and OOR-received VTXO lineage registration;
  • unroll source-lineage pause/resume;
  • matching server-side canonicality and policy gates.

Tests

  • Unit tests for reorg-aware chainsource ConfActor and SpendActor lifecycles.
  • Unit tests for height-based Done synthesis.
  • LNDBackend forwarder reorg tests.
  • Full txconfirm reorg lifecycle tests.
  • TestEnsureConfirmedDoneDuringReorgGapDropped pins the Done-during-reorg-gap behavior.
  • Systest coverage for chainsource and txconfirm reorg round trips.

Test Plan

  • CI green on chainsource, chainbackends, txconfirm, darepod unit suites
  • make lint-native clean
  • Reviewer sanity-check: Done / TxFinalized are policy-finality events at configured depth, not absolute consensus finality
  • Reviewer sanity-check: this PR remains an observation layer only; batch/VTXO interpretation is intentionally deferred
  • Reviewer sanity-check: bare reorg correlation IDs are intentional because gRPC-backed transports cannot reliably preserve richer reorg metadata

@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 implements reorg-awareness and finality tracking throughout the chain monitoring subsystem. Key changes include updating the LND backend and lndclient adapters to propagate reorg and completion signals, and enhancing ConfActor and SpendActor to process these events while synthesizing finality based on block height when backends do not provide a native signal. The TxBroadcasterActor was also updated to support reversible confirmation states and a terminal finalized state. Review feedback identifies a high-severity issue where notifying subscribers via blocking Tell calls could stall the main actor loop, as well as medium-severity concerns regarding potential resource leaks if block subscriptions fail during finality synthesis.

Comment thread txconfirm/actor.go
Comment thread chainsource/conf_actor.go Outdated
Comment thread chainsource/spend_actor.go Outdated
@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 4m 45s —— View job


PR Review: multi: Reorg-aware chainsource + chainbackends + txconfirm

Tasks

  • Gather context and understand the request
  • Read key changed files
  • Analyze chainsource actor changes (ConfActor, SpendActor)
  • Analyze txconfirm actor changes
  • Analyze chainbackends changes
  • Review for context/goroutine lifetime issues
  • Review blocking Tell concerns raised by Gemini
  • Post comprehensive review feedback

Overall, the design is sound and the implementation is well-structured. The lifecycle shape (Confirmed → Reorged → Confirmed → Done) is clean, the bare-correlation-ID rationale is well-documented, and the test coverage for the new paths is thorough. However there are a few issues that need attention before merge.

[Critical] FinalityDepth defaults to zero in production wiring

File: darepod/server.go:1612-1617

ChainSourceConfig is constructed without FinalityDepth:

chainActor := chainsource.NewChainSourceActor(
    chainsource.ChainSourceConfig{
        Backend: s.chainBackend,
        System:  s.actorSystem,
        // FinalityDepth not set → defaults to 0
    },
)

With FinalityDepth=0, height-based Done synthesis is disabled. The lndclient adapter's Done channel is explicitly allocated-but-never-written (per the code comments), so ConfDoneEvent/SpendDoneEvent never fire in production. Consequently, TxBroadcasterActor entries in TxStateConfirmed are never promoted to TxStateFinalized, and unregisterConfWatch is never called after confirmation — a resource leak on every confirmation until explicit CancelInterestReq.

DefaultFinalityDepth = 6 is defined in chainsource/chainsource.go:30 but never referenced at the wiring site. Fix: wire it.

Fix this →


[High] Blocking Tell in notifyConfirmed, notifyReorged, and attachExistingSubscriber

Files: txconfirm/actor.go:1336-1371, txconfirm/actor.go:816-820

notifyConfirmed and notifyReorged call subscriber.Tell(ctx, ...) synchronously in the actor's hot message-processing path:

func (a *TxBroadcasterActor) notifyConfirmed(...) {
    for _, subscriber := range entry.subscribers {
        err := subscriber.Tell(ctx, &TxConfirmed{...}) // blocks if mailbox full
        ...
    }
}

Tell on an actor mailbox blocks until the message is accepted or the context is cancelled. Since ctx here is context.Background()-derived (the actor's message-handling context), it won't cancel under normal operation. A single slow subscriber with a full mailbox will pin the entire TxBroadcasterActor loop, preventing fee bumps, other confirmations, and block processing.

This is a regression: before this PR, notifyConfirmed called notifyOneConfirmed → notifyOneTerminal, which spawns a goroutine so the slow subscriber cannot block the loop. The "best-effort" characterisation in the comment is accurate intent, but the implementation doesn't enforce it — Tell is not best-effort on a full mailbox.

The same problem exists in attachExistingSubscriber for the Confirmed replay at line 816.

The fix is to use a non-blocking send (select + default) or adopt the goroutine pattern from notifyOneTerminal for notifyConfirmed and notifyReorged.

Gemini flagged this as high-priority and it's correct.


[Medium] Backend Done dropping when state is not Confirmed

File: txconfirm/actor.go:670-677

// Only Confirmed entries can be finalized.
if state != TxStateConfirmed {
    return
}

If the backend fires Done while the entry is in AwaitingConfirmation (after a reorg but before re-confirmation), the signal is silently dropped. The height-based synthesizer also won't fire in this window because confirmHeight is reset to 0 on reorg. If the backend's own Done channel fires during this gap, finality is permanently lost for that watch — the actor will wait for re-confirmation that may never re-arm the block subscription to a new FinalityDepth target.

This scenario is unlikely on a well-behaved backend, but worth a comment at minimum, and possibly a guard that queues the Done signal and re-delivers it on the next Confirmed event.


[Medium] Block subscription failure silently breaks finality synthesis (Gemini confirmed)

Files: chainsource/conf_actor.go:324-335, chainsource/spend_actor.go:279-290

if a.cfg.FinalityDepth > 0 && a.blockReg == nil {
    reg, err := a.cfg.Backend.RegisterBlocks(a.ctx)
    if err != nil {
        log.WarnS(a.ctx, "Failed to register block epoch ...", err)
        // blockEpochs stays nil → height-based Done never fires
    } else {
        a.blockReg = reg
        blockEpochs = reg.Epochs
    }
}

If RegisterBlocks fails, blockEpochs remains nil. With the backend's Done channel also never written (lndclient case), the actor runs forever without delivering Done. Since the conf/spend watch is kept alive by the txconfirm layer until Done arrives, this produces a resource leak per watched tx/outpoint. At minimum, this warrants a higher-severity log (consider ErrorS since it's an internal infrastructure failure) and documentation that callers relying on Done for eviction must handle the absent-Done case defensively.


[Low] SpendReorg struct is defined but unreferenced

File: chainsource/backend.go:259-264

type SpendReorg struct {
    ReorgDepth int32
}

This type is not referenced anywhere in the codebase (confirmed with grep -rn SpendReorg). The SpendRegistration.Reorged channel carries struct{}{} signals, not SpendReorg. Either delete this type or reference it where intended.


[Low] txconfirm/CLAUDE.md not updated

txconfirm/CLAUDE.md still describes the pre-PR terminal states and message types. It should be updated to reflect:

  • TxStateFinalized (new terminal state)
  • TxReorged / TxFinalized (new notification types)
  • TxStateConfirmed is now reversible (not terminal)
  • confirmationReorgedMsg, confirmationDoneMsg (new internal messages)
  • Updated "Sends" / "Receives" section
  • Updated invariants (e.g. "Terminal eviction" now triggered by Finalized, not Confirmed)

Minor observations

  • conf_actor.go depth calculation: depth := epoch.Height - a.confirmHeight + 1 uses unchecked int32 subtraction. Protected by the confirmHeight == 0 guard, but if epoch.Height < a.confirmHeight (a reorg the actor hasn't seen yet), depth goes negative and the < FinalityDepth guard saves it. Worth a comment on the guard's role.

  • Defer order in lnd.go forwarder goroutines: The defers in the new RegisterConf/RegisterSpend goroutines run event.Cancel() first (last deferred → first executed), then cancel(), then channel closes. This is intentional and correct, but the order is implicit — a comment noting that event.Cancel() must precede channel closes would help future readers.

  • Test: no coverage for Done-in-AwaitingConfirmation scenario: txconfirm/reorg_test.go is short (83 lines); none of the tests drive the case where the backend fires Done between a reorg and a re-confirmation. Consider adding it given the dropping behaviour described above.

@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch from 1450ae5 to 92de073 Compare May 13, 2026 17:03
@ellemouton
ellemouton marked this pull request as ready for review May 13, 2026 17:18
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch from 92de073 to 29b9872 Compare May 13, 2026 17:18
@ellemouton

Copy link
Copy Markdown
Member Author

@claude re review

@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 4m 17s —— View job


Re-review: multi: Reorg-aware chainsource + chainbackends + txconfirm

Tasks

  • Gather context
  • Read key changed files
  • Check previous review issues
  • Post updated review

Status of previous review findings

Every critical and high issue from the first pass has been addressed. Here is the full reconciliation:

[Critical] FinalityDepth not wired in darepod → FIXED

darepod/server.go:1622 now sets FinalityDepth: chainsource.DefaultFinalityDepth. The commit message ("Enable height-based finality on the chainsource actor") and the inline comment both explain why this is load-bearing for lndclient-backed transports. ✅

[High] Blocking Tell in notifyConfirmed / notifyReorged / attachExistingSubscriber → FIXED

notifyReversibleAsync (txconfirm/actor.go:1405-1440) was added. Each subscriber gets its own goroutine, bounded by reversibleNotifyTimeout = 1s, with a context detached from the actor transaction so the DB transaction is not held open. The goroutine exits on timeout rather than blocking the actor loop indefinitely. The CLAUDE.md invariant block documents this explicitly. ✅

[Medium] Block subscription failure silently breaks finality synthesis → FIXED

chainsource/finality.go introduces registerBlocksForFinality with a bounded 3-attempt backoff (100ms / 500ms / 2s). Both ConfActor and SpendActor use it when arming height-based synthesis. If all retries fail, a WarnS explains the fallback path (backend's own Done channel). ✅

[Medium] Backend Done dropping when state is not Confirmed → ADDRESSED WITH DOCUMENTATION

The behaviour is retained intentionally (a hypothetical future backend that fires Done during a reorg gap would render the watch unrecoverable, so the warn-and-drop with a comment naming the fix is the right tradeoff). The long multi-paragraph comment in txconfirm/actor.go:688-721 and the CLAUDE.md invariant both explain all three possible origins for the anomaly. ✅

[Low] SpendReorg struct unreferenced → NON-ISSUE (misidentification)

The type in the previous review was confused with SpendReorgedEvent, which is properly defined in chainsource/messages.go:555 and referenced throughout spend_actor.go, transform.go, and the test suite. ✅

[Low] txconfirm/CLAUDE.md not updated → FIXED

txconfirm/CLAUDE.md now documents TxStateFinalized, TxReorged, TxFinalized, the reversible fire-and-forget delivery model, confirmationReorgedMsg, confirmationDoneMsg, and the updated terminal-eviction invariant. ✅


Remaining finding

[Low] chainsource/CLAUDE.md is still outdated

txconfirm/CLAUDE.md was correctly updated, but chainsource/CLAUDE.md was not. The stale content:

  • ChainSourceConfig is described as "Backend ChainBackend, System *actor.ActorSystem, Log fn.Option[btclog.Logger]"FinalityDepth uint32 is missing.
  • No mention of the six new event types: ConfReorgedEvent, ConfDoneEvent, SpendReorgedEvent, SpendDoneEvent (all in messages.go).
  • No mention of the six new mapping helpers: MapConfReorgedEvent, MapConfDoneEvent, MapSpendReorgedEvent, MapSpendDoneEvent (all in transform.go).
  • RegisterConfRequest and RegisterSpendRequest now carry NotifyReorged and NotifyDone optional refs — not reflected.
  • No invariant capturing the multi-shot lifecycle (Confirmed → Reorged → Confirmed → Done / Spend → Reorged → Spend → Done) or the height-based Done synthesis.
  • ConfRegistration / SpendRegistration now have Reorged and Done channels — not reflected.

The chainsource package is the foundation of the reorg-safety stack; callers who read its CLAUDE.md to understand the registration contract will see an incomplete picture of what the channels now carry.

Fix this →


Minor observations (not blocking)

  • RegisterSpendNtfn lacks the 15-second timeout guard that RegisterConfirmationsNtfn has (lndclient_adapters.go:300-308). This is pre-existing, not introduced by this PR, but since the spend registration was touched to add WithReOrgChan, it's a natural opportunity to add parity with the conf path. The chainbackends/CLAUDE.md invariant says registrations enforce a 15-second timeout — that claim is now false for the spend path.

  • Depth calculation comment: the confirmHeight == 0 guard comment at conf_actor.go:372-384 is excellent — it calls out exactly why resetting to 0 on reorg is safe and why epoch.Height < confirmHeight doesn't produce a signed-integer surprise. The same guard in spend_actor.go:327-328 is present but has no corresponding comment. Worth mirroring the explanation there.


Verdict

The implementation is correct and complete. All structural issues identified in the first review are resolved. The remaining chainsource/CLAUDE.md gap is the only item worth addressing before merge; everything else is informational.

@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch 12 times, most recently from 53b0b2d to 4b1f063 Compare May 15, 2026 17:48
ellemouton added a commit that referenced this pull request May 16, 2026
Make confRegistration / spendRegistration multi-shot reorg-aware:

  - Add Reorged / Done channels alongside Confirmed / Spend on the
    returned chainsource.ConfRegistration and SpendRegistration so
    the backend now satisfies the multi-shot contract that PR #422
    introduced for the LND-backed backend.
  - Track a per-registration state (stateWatching | statePositive)
    plus the last-delivered block hash so the reorg handler can
    decide whether a given reorg invalidates a previous delivery.
  - Stop deleting registrations on the first positive event. A
    confirmation that survives a future reorg is now repeatedly
    re-deliverable on the same registration; the chainsource conf /
    spend actor synthesizes Done at FinalityDepth from block epochs.

Subscribe to the TipPoller's new ReorgSubscribe stream alongside the
existing TipBlock stream via BestBlockAndSubscribeAll. When a
ReorgEvent arrives, walk every active conf / spend registration and:

  - For each one in statePositive whose last block hash is in the
    disconnected set, fire Reorged (non-blocking; the channel is
    buffered to 1 and a coalesced reorg signal is semantically
    correct -- the consumer re-queries either way), reset state to
    stateWatching, and re-check status against the new chain so a
    re-confirmation / re-spend fires Confirmed / Spend immediately
    in the same handler turn.
  - For registrations whose last block hash was never resolved
    (typically a transient Esplora failure at delivery time) we
    leave them alone; the broad tip-driven re-check still runs and
    will catch up on the next block.

Cancel now uses a regMu-protected once-style check so a
double-Cancel is a safe no-op. Channel sends are performed outside
regMu so a slow consumer never blocks the broad re-check goroutine.
ellemouton added a commit that referenced this pull request May 16, 2026
Make confRegistration / spendRegistration multi-shot reorg-aware:

  - Add Reorged / Done channels alongside Confirmed / Spend on the
    returned chainsource.ConfRegistration and SpendRegistration so
    the backend now satisfies the multi-shot contract that PR #422
    introduced for the LND-backed backend.
  - Track a per-registration state (stateWatching | statePositive)
    plus the last-delivered block hash so the reorg handler can
    decide whether a given reorg invalidates a previous delivery.
  - Stop deleting registrations on the first positive event. A
    confirmation that survives a future reorg is now repeatedly
    re-deliverable on the same registration; the chainsource conf /
    spend actor synthesizes Done at FinalityDepth from block epochs.

Subscribe to the TipPoller's new ReorgSubscribe stream alongside the
existing TipBlock stream via BestBlockAndSubscribeAll. When a
ReorgEvent arrives, walk every active conf / spend registration and:

  - For each one in statePositive whose last block hash is in the
    disconnected set, fire Reorged (non-blocking; the channel is
    buffered to 1 and a coalesced reorg signal is semantically
    correct -- the consumer re-queries either way), reset state to
    stateWatching, and re-check status against the new chain so a
    re-confirmation / re-spend fires Confirmed / Spend immediately
    in the same handler turn.
  - For registrations whose last block hash was never resolved
    (typically a transient Esplora failure at delivery time) we
    leave them alone; the broad tip-driven re-check still runs and
    will catch up on the next block.

Cancel now uses a regMu-protected once-style check so a
double-Cancel is a safe no-op. Channel sends are performed outside
regMu so a slow consumer never blocks the broad re-check goroutine.
ellemouton added a commit that referenced this pull request May 18, 2026
Make confRegistration / spendRegistration multi-shot reorg-aware:

  - Add Reorged / Done channels alongside Confirmed / Spend on the
    returned chainsource.ConfRegistration and SpendRegistration so
    the backend now satisfies the multi-shot contract that PR #422
    introduced for the LND-backed backend.
  - Track a per-registration state (stateWatching | statePositive)
    plus the last-delivered block hash so the reorg handler can
    decide whether a given reorg invalidates a previous delivery.
  - Stop deleting registrations on the first positive event. A
    confirmation that survives a future reorg is now repeatedly
    re-deliverable on the same registration; the chainsource conf /
    spend actor synthesizes Done at FinalityDepth from block epochs.

Subscribe to the TipPoller's new ReorgSubscribe stream alongside the
existing TipBlock stream via BestBlockAndSubscribeAll. When a
ReorgEvent arrives, walk every active conf / spend registration and:

  - For each one in statePositive whose last block hash is in the
    disconnected set, fire Reorged (non-blocking; the channel is
    buffered to 1 and a coalesced reorg signal is semantically
    correct -- the consumer re-queries either way), reset state to
    stateWatching, and re-check status against the new chain so a
    re-confirmation / re-spend fires Confirmed / Spend immediately
    in the same handler turn.
  - For registrations whose last block hash was never resolved
    (typically a transient Esplora failure at delivery time) we
    leave them alone; the broad tip-driven re-check still runs and
    will catch up on the next block.

Cancel now uses a regMu-protected once-style check so a
double-Cancel is a safe no-op. Channel sends are performed outside
regMu so a slow consumer never blocks the broad re-check goroutine.
ellemouton pushed a commit that referenced this pull request May 22, 2026
…connector-radix

server: Fix fraud-response safety gate connector radix
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch from aebb4c7 to 3877221 Compare May 27, 2026 23:39
ellemouton added a commit that referenced this pull request May 27, 2026
Make confRegistration / spendRegistration multi-shot reorg-aware:

  - Add Reorged / Done channels alongside Confirmed / Spend on the
    returned chainsource.ConfRegistration and SpendRegistration so
    the backend now satisfies the multi-shot contract that PR #422
    introduced for the LND-backed backend.
  - Track a per-registration state (stateWatching | statePositive)
    plus the last-delivered block hash so the reorg handler can
    decide whether a given reorg invalidates a previous delivery.
  - Stop deleting registrations on the first positive event. A
    confirmation that survives a future reorg is now repeatedly
    re-deliverable on the same registration; the chainsource conf /
    spend actor synthesizes Done at FinalityDepth from block epochs.

Subscribe to the TipPoller's new ReorgSubscribe stream alongside the
existing TipBlock stream via BestBlockAndSubscribeAll. When a
ReorgEvent arrives, walk every active conf / spend registration and:

  - For each one in statePositive whose last block hash is in the
    disconnected set, fire Reorged (non-blocking; the channel is
    buffered to 1 and a coalesced reorg signal is semantically
    correct -- the consumer re-queries either way), reset state to
    stateWatching, and re-check status against the new chain so a
    re-confirmation / re-spend fires Confirmed / Spend immediately
    in the same handler turn.
  - For registrations whose last block hash was never resolved
    (typically a transient Esplora failure at delivery time) we
    leave them alone; the broad tip-driven re-check still runs and
    will catch up on the next block.

Cancel now uses a regMu-protected once-style check so a
double-Cancel is a safe no-op. Channel sends are performed outside
regMu so a slow consumer never blocks the broad re-check goroutine.
ellemouton added a commit that referenced this pull request May 27, 2026
Make confRegistration / spendRegistration multi-shot reorg-aware:

  - Add Reorged / Done channels alongside Confirmed / Spend on the
    returned chainsource.ConfRegistration and SpendRegistration so
    the backend now satisfies the multi-shot contract that PR #422
    introduced for the LND-backed backend.
  - Track a per-registration state (stateWatching | statePositive)
    plus the last-delivered block hash so the reorg handler can
    decide whether a given reorg invalidates a previous delivery.
  - Stop deleting registrations on the first positive event. A
    confirmation that survives a future reorg is now repeatedly
    re-deliverable on the same registration; the chainsource conf /
    spend actor synthesizes Done at FinalityDepth from block epochs.

Subscribe to the TipPoller's new ReorgSubscribe stream alongside the
existing TipBlock stream via BestBlockAndSubscribeAll. When a
ReorgEvent arrives, walk every active conf / spend registration and:

  - For each one in statePositive whose last block hash is in the
    disconnected set, fire Reorged (non-blocking; the channel is
    buffered to 1 and a coalesced reorg signal is semantically
    correct -- the consumer re-queries either way), reset state to
    stateWatching, and re-check status against the new chain so a
    re-confirmation / re-spend fires Confirmed / Spend immediately
    in the same handler turn.
  - For registrations whose last block hash was never resolved
    (typically a transient Esplora failure at delivery time) we
    leave them alone; the broad tip-driven re-check still runs and
    will catch up on the next block.

Cancel now uses a regMu-protected once-style check so a
double-Cancel is a safe no-op. Channel sends are performed outside
regMu so a slow consumer never blocks the broad re-check goroutine.
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch from c86210e to 1ab3019 Compare June 29, 2026 15:05
ellemouton added a commit that referenced this pull request Jun 29, 2026
Make confRegistration / spendRegistration multi-shot reorg-aware:

  - Add Reorged / Done channels alongside Confirmed / Spend on the
    returned chainsource.ConfRegistration and SpendRegistration so
    the backend now satisfies the multi-shot contract that PR #422
    introduced for the LND-backed backend.
  - Track a per-registration state (stateWatching | statePositive)
    plus the last-delivered block hash so the reorg handler can
    decide whether a given reorg invalidates a previous delivery.
  - Stop deleting registrations on the first positive event. A
    confirmation that survives a future reorg is now repeatedly
    re-deliverable on the same registration; the chainsource conf /
    spend actor synthesizes Done at FinalityDepth from block epochs.

Subscribe to the TipPoller's new ReorgSubscribe stream alongside the
existing TipBlock stream via BestBlockAndSubscribeAll. When a
ReorgEvent arrives, walk every active conf / spend registration and:

  - For each one in statePositive whose last block hash is in the
    disconnected set, fire Reorged (non-blocking; the channel is
    buffered to 1 and a coalesced reorg signal is semantically
    correct -- the consumer re-queries either way), reset state to
    stateWatching, and re-check status against the new chain so a
    re-confirmation / re-spend fires Confirmed / Spend immediately
    in the same handler turn.
  - For registrations whose last block hash was never resolved
    (typically a transient Esplora failure at delivery time) we
    leave them alone; the broad tip-driven re-check still runs and
    will catch up on the next block.

Cancel now uses a regMu-protected once-style check so a
double-Cancel is a safe no-op. Channel sends are performed outside
regMu so a slow consumer never blocks the broad re-check goroutine.
@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown

Review: multi: Reorg-aware chainsource + chainbackends + txconfirm

Thanks for the very well-documented PR — the commit messages, the package CLAUDE.md updates, and the inline rationale comments make a genuinely complex state-machine change reviewable. The layering (observation substrate only, batch/VTXO interpretation deferred) is clearly communicated, and the test coverage (unit + systest round-trips through real bitcoind/lnd) is strong. Below are the findings, ordered by importance.

🔴 Cross-channel ordering hazard at the lndclient bridge (please verify)

In chainbackends/lndclient_adapters.go, the reorg signal and the (re-)confirmation are forwarded to the downstream chainntnfs channels by two independent goroutines:

  • the existing forwarder copies confChan → Confirmed;
  • the new bridge copies reorgPing → NegativeConf (and reorgPing → Reorg on the spend side).

LNDBackend.RegisterConf then consumes Confirmed / NegativeConf / Done in a single select, where Go gives no ordering guarantee between two ready channels. So even if lndclient emits reorg strictly before the subsequent re-confirmation, the adapter can deliver them out of order to the chainsource ConfActor.

Walking the bad interleaving through to txconfirm:

  1. ConfActor receives the re-Confirmed(H_new) first → sets confirmHeight = H_new, re-delivers TxConfirmed.
  2. Then receives Reorged → delivers TxReorged and resets confirmHeight = 0.

No further Confirmed will arrive (lndclient already sent it), so confirmHeight stays 0, height-based Done synthesis never fires (conf_actor.go:395 guard), and the txconfirm FSM is left in AwaitingConfirmation for a tx that is actually confirmed on the canonical chain. The watch and sub-actor then leak for the lifetime of the daemon, and the boarding-sweep handler sits in "waiting for re-confirmation" forever.

In practice a reorg and the replacement-block confirmation are seconds apart, so the forwarders will almost always drain in order — which is likely why the systests pass. But the race is structural. Consider forwarding the full lifecycle through a single goroutine/select (so cross-channel order is preserved end to end), or document why reordering is provably safe here. The same two-goroutine split exists on the spend path.

🟡 registerBlocksForFinality can block the monitor goroutine

registerBlocksForFinality (finality.go) bounds the backoff between attempts, but each individual backend.RegisterBlocks(ctx) call is made with the actor root context (no per-call timeout). On the lndclient transport, if that call hangs, the conf/spend monitor goroutine is stuck and stops draining Confirmed/Reorged/Done for the duration. The reorg buffer (size 8) absorbs a burst, but a genuinely wedged RegisterBlocks blocks the whole watch. A bounded context.WithTimeout per attempt (mirroring the 10s used in handleRegisterConf) would make this consistent with the rest of the file.

🟡 Confirmed-while-Confirmed path doesn't refresh FSM height

In handleConfirmationObserved (actor.go), an already-Confirmed entry that receives another Confirmed without an intervening Reorged re-delivers TxConfirmed but does not advance the FSM, so trackedTxStateConfirmed.ConfirmHeight keeps the stale height. The comment notes this is "unexpected on a well-behaved backend," which is fair — but note that the ordering hazard above is exactly a path that produces it. If you address the ordering issue this becomes moot; if not, it's worth deciding whether the height should be updated.

🟢 Minor

  • Magic strings for notification kind. "confirmed" / "finalized" / "failed" are passed as bare string literals across notifyOneConfirmed, notifyOneFinalized, handleTerminalNotifyResult, etc. A small typed/const set would prevent a silent mismatch (e.g. the kind == "confirmed" branch in handleTerminalNotifyResult silently mis-routing if a literal drifts).
  • Indefinite watch after reorg-without-reconfirm. A tx that confirms then reorgs out and never re-confirms leaves confirmHeight = 0, so the sub-actor + block subscription stay alive until context cancel. This is consistent with the "observation layer stays live" semantics described in the PR, but it's an unbounded-resource case worth a sentence in the docs (the later BatchCanonicalityManager presumably owns eviction here).
  • classifyTxconfirmNotificationForBoardingSweep zero-value fallthrough correctly maps to BoardingSweepTxStatusUnknowndefault warn arm in handleSweepTxNotification. Good defensive handling; nice that the classifier is shared with the systest so prod wiring can't drift.

Style / conventions

Looks compliant with CLAUDE.md: 8-space tabs, function-name-prefixed comments, structured logging via InfoS/WarnS with slog/static messages, early returns. No raw SQL, no edited generated code. The //nolint:contextcheck / //nolint:containedctx annotations are justified in-context.

Tests

Coverage is a highlight: reorg lifecycle unit tests for both sub-actors, height-based Done synthesis, drainToLatestEpoch edge cases, LNDBackend forwarder teardown, the full txconfirm reorg FSM, TestEnsureConfirmedDoneDuringReorgGapDropped, and end-to-end systests over real lnd/bitcoind. One gap worth considering: a unit test that asserts behavior when Reorged and re-Confirmed are delivered out of order to the ConfActor would pin down the resolution of the first finding above.

Overall this is a solid, carefully-staged foundation. The ordering hazard is the one item I'd want resolved or explicitly ruled out before merge.

🤖 Generated with Claude Code

@ellemouton

Copy link
Copy Markdown
Member Author

Addressed the automated review in 5321ad4 + bc869df (pushed to reorg-safe-chainsource).

🔴 Cross-channel ordering hazard at the lndclient bridge — fixed. lndclient drives the whole confirmation lifecycle off a single ordered gRPC receive loop, but the adapter split it across two channels (the confirmation channel and the WithReOrgChan ping) and forwarded each on its own goroutine. That let the downstream ConfActor select consume a re-Confirmed before the Reorged: it would set confirmHeight to the new height, a later Reorged would reset it to 0, and with no further Confirmed coming the height-based Done synthesis never fired — leaking the watch + txconfirm FSM for the lifetime of the daemon.

Fix: forward the whole lifecycle through a single goroutine (forwardOrderedReorg / forwardOrderedSpendReorg) that drains a pending reorg with priority before any confirmation and hands every event off with a blocking send. The single downstream consumer now observes events strictly in lndclient's emission order (reorg before re-confirmation), closing the race. The spend path had the identical two-goroutine split and gets the same single-forwarder treatment.

🟡 Unbounded RegisterBlocks attempt in finality synthesis — fixed. registerBlocksForFinality bounded the backoff between attempts but ran each RegisterBlocks on the actor root context with no per-call timeout, so a wedged lndclient stream could block the monitoring goroutine indefinitely. Each attempt is now wrapped in a bounded context (finalityBlockSubscriptionAttemptTimeout, 10s, mirroring handleRegisterConf). The subscription's own lifetime is unaffected — the adapter derives the stream from its own background context, so cancelling the attempt context only bounds the registration call.

Verification:

  • New deterministic unit tests (lndclient_ordering_test.go) pre-load both source channels and assert the reorg is delivered before the confirmation/spend.
  • TestChainSourceConfReorgRoundTrip + TestTxConfirmReorgRoundTrip reorg systests still pass against real bitcoind+LND.
  • build / vet / fmt / lint-changed-local clean.

@claude

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review: reorg-safe chain observation

Thanks for the thorough PR description and the careful split between the observation substrate and the later canonicality layer — it made reviewing the intent much easier. The actor lifecycle changes, the reversible-vs-terminal notification split in txconfirm, and the height-based Done synthesis are all coherent and well-commented. A few findings, ordered by severity.

🔴 High

1. In-process lnd forwarder doesn't preserve Reorged-before-reconfirm ordering — the exact bug the lndclient path was built to avoid. chainbackends/lnd.go:343-392 (RegisterConf) and :452-504 (RegisterSpend).

The lndclient adapter forwards reorg pings and (re-)confirmations through a single ordered goroutine with a priority drain (lndclient_adapters.go:262-271 + forwardOrderedReorg), with a comment spelling out the hazard: a downstream select can consume a re-Confirmed before the Reorged, reset confirmHeight/spendHeight to 0 with no further Confirmed coming, and strand the watch.

The in-process LNDBackend forwarder uses a naive multi-arm select over event.Confirmed / event.NegativeConf feeding separately-buffered downstream channels (confChan cap 1, reorgChan cap 8). When a reorg disconnects and the tx re-confirms, lnd can have both buffered before the forwarder reads either; the forwarder may forward Confirmed then Reorged, and the ConfActor select (reorg arm resets confirmHeight=0; spend arm at spend_actor.go:317 resets spendHeight=0) can then apply them out of order and park finality synthesis. Recommend routing the in-process forwarders through the same ordered/priority-drain discipline as the lndclient adapters.

🟡 Medium

2. Spend actor is missing the reorgAware self-termination guard the conf actor has. chainsource/spend_actor.go:278.

ConfActor computes reorgAware := a.notifyReorged.IsSome() || a.notifyDone.IsSome() and exits after the first event with if !reorgAware || a.promise.IsSome() { return } (conf_actor.go:266,315). The spend equivalent only checks if a.promise.IsSome() { return }. So an actor-mode spend watch registered with no NotifyReorged/NotifyDone (the existing callers in fraud, unroll, wallet/boarding_sweep_actor.go) becomes multi-shot instead of single-shot, contradicting the backwards-compat contract documented in chainsource/CLAUDE.md. With FinalityDepth > 0 it also arms a block-epoch subscription for a watch that asked for none and drops the synthesized events into None refs — wasted work plus a block sub that lives until the caller cancels. Adding the same reorgAware gate to the spend Spend arm restores symmetry.

3. Conf-watch leak on OnStop for terminal Finalized/Failed entries that still hold a registered watch. txconfirm/actor.go:469-486.

The terminal branch stops the FSM and evicts broadcaster state but never calls unregisterConfWatch; only the non-terminal branch (line 488) does. Before this PR that was safe (Confirmed unregistered eagerly, Failed rarely held a watch). Now Finalized is terminal and routinely holds a live conf watch until its TxFinalized is acked — handleConfirmationDone only unregisters after notifyFinalized returns true. If delivery is deferred (slow/durable subscriber timing out) and the daemon shuts down before the next retry, the chainsource conf sub-actor for that txid leaks. Mirroring the non-terminal branch (if entry.confWatchRegistered { a.unregisterConfWatch(...) }, or routing through evictTerminal) fixes it.

4. Reversible TxReorged and the following re-TxConfirmed can be delivered out of order to a subscriber. txconfirm/actor.go notifyReorged / notifyConfirmed re-confirm path via notifyReversibleAsync.

Each reversible event fans out on a fresh per-subscriber goroutine with no per-subscriber ordering, so a Confirmed→Reorged→Confirmed sequence across two blocks can land at a slow/durable subscriber as re-TxConfirmed before the earlier TxReorged, leaving it believing the tx is reorged-out until TxFinalized corrects it. This is within the documented "reversible deliveries are best-effort," but reordering is a distinct hazard from a dropped event — worth either an explicit note on TxReorged/TxConfirmed or per-subscriber serialization.

🟢 Low / nits

  • registerBlocksForFinality blocks the single monitoring goroutine inline for up to ~32s worst case (finality.go:52-85). The comment argues no further events are expected on that watch during the window, but a reorg right after first confirmation is exactly the targeted scenario; delivery is delayed (recoverable, not a deadlock). Consider arming the sub off the event loop. Discussion point, not a blocker.
  • drainToLatestEpoch returns the last-received epoch, not the max-height one (conf_actor.go:434-452). The doc comment anticipates out-of-order historical re-delivery; tracking max(Height) would match the stated rationale (only delays finality otherwise).
  • Inaccurate comment at conf_actor.go:387-394: for signed int32, epoch.Height - 0 doesn't "wrap to a large value." The guard itself is correct and load-bearing; only the explanation is off (the spend actor's parallel comment is phrased correctly).
  • terminalNotifyInflight key can be stranded if selfRef is nil (completeTerminalNotifyAsync, ~actor.go:2042) — effectively unreachable since selfRef is always set, but the nil path silently strands the key rather than clearing/logging it.

Tests

Coverage is strong — reorg lifecycle unit tests across both actors, height-based Done synthesis, the LNDBackend forwarder, full txconfirm reorg round-trips, the Done-during-reorg-gap pin, and systest coverage. Two gaps worth adding: (a) an in-process LNDBackend ordering test mirroring lndclient_ordering_test.go would have caught finding #1; (b) a test asserting a plain actor-mode spend watch (no reorg/done refs) terminates after one spend would catch #2.

Nice work overall — the high/medium items are mostly about bringing the in-process path and the spend actor up to the same discipline the lndclient/conf paths already establish.

🤖 Generated with Claude Code

@claude

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review: Reorg-aware chainsource + chainbackends + txconfirm

Thanks for the thorough write-up — the layered framing (observation substrate now, batch/VTXO interpretation later) is clear and the docs/CLAUDE.md updates are excellent. The state machines in txconfirm/states.go and the actor lifecycle in chainsource are clean, and the confirmHeight==0 reorg-reset guard with the depth = epoch.Height - confirmHeight + 1 synthesis checks out with no off-by-one.

The recurring risk across this PR is event ordering where reorg signals travel a different path than the confirmation they revert. A few of these are real correctness bugs. Findings below, most-severe first.


🔴 High — chainbackends/lnd.go: re-forwarder loses reorg/confirmation ordering -> stranded watch

chainbackends/lnd.go:343-394 (RegisterConf; same shape at :452-505 RegisterSpend).

forwardOrderedReorg in lndclient_adapters.go goes to real trouble to serialize reorg-before-reconfirmation onto two cap-1 channels with priority-drained, one-at-a-time hand-offs. But a cap-1 buffered send returns as soon as the item is in the buffer — it does not wait for the consumer. So the adapter can leave a reorg buffered in negativeConf and a re-Confirmed buffered in orderedConfirmed simultaneously. The lnd.go forwarder then reads both with a plain select, which picks a ready arm at random — discarding the ordering the adapter just established.

Failure scenario (verified end-to-end):

  1. lndclient emits reorg-ping, then the replacement Confirmed. Adapter buffers reorg into negativeConf, then Confirmed into orderedConfirmed.
  2. lnd.go select sees both ready, picks Confirmed first -> forwards to confChan, then forwards reorg to reorgChan.
  3. In conf_actor.go, the Confirmed arm sets confirmHeight = <reconfirm height> (conf_actor.go:313); the Reorged arm then runs and resets confirmHeight = 0 (conf_actor.go:355).
  4. No further Confirmed arrives. The confirmHeight == 0 finality guard (conf_actor.go:387) now skips forever -> Done is never synthesized, the sub-actor leaks for the daemon's lifetime, and the last delivered event to the consumer is Reorged even though the tx is re-confirmed on the canonical chain.

This is exactly the bug the "Order reorg/confirmation at the lndclient bridge" commit set out to prevent — it is just reintroduced one layer up. Fix options: apply the same priority-drain in the lnd.go forwarder, or have the adapter deliver both signals over a single ordered channel rather than re-splitting them.

Note: lndclient_ordering_test.go only exercises the adapter forwarders in isolation; nothing covers the second lnd.go forwarder between them and the actor, so this regression is invisible to the suite.


🔴 High — txconfirm: TxReorged can be delivered before (or without) the initial TxConfirmed

txconfirm/actor.go:1819 (notifyReorged) iterates all subscribers, including those whose initial TxConfirmed is still owed (pendingConfirmed == true).

Reversible events (TxReorged, re-TxConfirmed) go out fire-and-forget on a detached goroutine (notifyReversibleAsync, :1844), while a slow durable subscriber's initial TxConfirmed is deferred to the reliable retry path. The FSM advances to Confirmed before notifyConfirmed completes, so a reorg arriving in that window passes the state == TxStateConfirmed guard and enqueues TxReorged. Because the deferred TxConfirmed and the fire-and-forget TxReorged race, the consumer can observe TxReorged with no prior TxConfirmed, or TxConfirmed after TxReorged — leaving it believing the tx is confirmed when it actually reorged out.

The reorg regression test (actor_test.go:2120-2160) deliberately waits for TxConfirmed to land before driving the reorg, so this window is untested. Suggested fix: skip pendingConfirmed subscribers in notifyReorged (they get live state on their eventual initial delivery anyway).


🟠 Medium — txconfirm: reversible event can arrive after terminal TxFinalized

Same root cause: TxFinalized is delivered synchronously from the actor goroutine via the reliable path, while TxReorged/re-TxConfirmed are fire-and-forget. For a re-confirming subscriber (pendingConfirmed == false) there is no happens-before relationship between an in-flight reversible Tell and the later synchronous TxFinalized Tell. A consumer that drops reorg-recovery bookkeeping on the terminal TxFinalized can then receive a stale TxReorged/TxConfirmed after teardown. The package docs cover "reversible delivery superseded by the next event," but a reversible delivery landing after the terminal event is not covered by that rationale. Per-subscriber serialization of reversible deliveries (so they cannot overtake/trail an adjacent terminal) would close both this and the High finding above.


🟠 Medium — chainsource: registerBlocksForFinality blocks the monitor select loop

conf_actor.go:328 (and spend_actor.go:291) call registerBlocksForFinality inline in the select loop on the first positive event. With the bounded backoff (3 attempts x 10s timeout + backoff) this can block the loop for ~30s, during which it does not service Reorged, Done, or ctx.Done(). That delays reorg observation and graceful shutdown until the current attempt times out. Consider arming the block subscription off the select loop (e.g. a short-lived helper goroutine that hands the registration back via a channel) so the loop stays responsive while retries proceed.


🟡 Low — chainsource: NotifyReorged-only watch is torn down at finality with no Done

reorgAware is true if either NotifyReorged or NotifyDone is set (conf_actor.go:266), but finality synthesis unconditionally calls deliverConfDone (a no-op when notifyDone is unset) and then returns, cancelling the registration. A caller that registered only for Reorged therefore silently stops receiving reorg notifications at FinalityDepth. Intended (finality => stop watching), or should a reorg-only watch keep observing? Worth a comment either way.

🟡 Low — chainsource: stale lastEvent after a reorg gap

lastEvent is not cleared on reorg (conf_actor.go Reorged arm; spend_actor.go:325). A backend Done arriving after a reorg with no re-confirmation delivers the reorged-out event's Txid/Outpoint for pkScript-only watches. Minor, but clearing lastEvent on reorg would make the Done payload accurate.

🟡 Low — wallet/boarding_sweep_actor.go: unguarded MarkBoardingSweepFailed on the Failed arm

The Failed arm of handleSweepTxNotification calls MarkBoardingSweepFailed with no status guard. It is unconditional in the store, so if TxFailed ever followed a confirmed-then-reorged sweep it would roll intents back while the (irreversible, txid-keyed) sweep-confirmed ledger legs remain booked -> store/ledger divergence. This is latent only today because txconfirm's contract is that TxFailed never follows a real TxConfirmed (only structural ErrNonTRUCParent at submit time is terminal). Given the rest of this PR loosens that very lifecycle, a defensive no-op-when-already-confirmed guard would make the handler robust rather than reliant on that coupling. The reorg/reconfirm path itself is sound — the actor correctly no-ops on TxReorged, re-runs reconcileSweepInputsOnConfirm on reconfirm, ledger emission is txid-idempotent, and it only ever moves the wallet's own funds, so there is no premature-spend/double-spend risk.


Test coverage

The state machines, depth synthesis, and lifecycle paths are well covered, but the ordering hazards above are exactly what current tests step around: lndclient_ordering_test.go tests the adapter in isolation (not the lnd.go re-forwarder), and the txconfirm/chainsource reorg tests wait for the positive event to land before injecting the reorg. A test that injects reorg-then-reconfirm with both signals buffered (or a slow subscriber whose initial confirm is deferred) would catch the High findings.

Nice work overall — the design is solid and the issues are concentrated in the cross-channel ordering seams rather than the core logic.

@ellemouton

Copy link
Copy Markdown
Member Author

Addressed the second review pass (the 🔴 ordering hazards + the medium/low findings). Pushed 6 commits to reorg-safe-chainsource.

The ordering fix is now correct for both interleavings (101168f)

You were right that the priority-drain only papered over the common case. I worked through the two interleavings for a single watched tx:

  • reorg → re-confirm (common): reorg-first is correct.
  • confirm → reorg-of-that-block, no re-confirm (rare): reorg-first is wrong — it leaves a stale confirm winning and synthesizes a false Done for a tx that's gone.

"Reorg-first" is a heuristic, not ordering. And it can't be fixed at any single forwarder hop because Confirmed/Reorged are separate channels re-split at every hop. So instead I stamp a per-registration monotonic sequence at the single LNDBackend forwarder (the one place that sees the whole lifecycle in arrival order) onto TxConfirmation/SpendDetail and the Reorged signal, and the ConfActor/SpendActor apply highest-seq-wins, discarding any event whose seq doesn't exceed the highest applied. Delivery interleaving downstream is now irrelevant — both cases resolve to the highest-sequence outcome. Seq 0 = a backend that never reorgs (always-apply), preserving existing behavior. Added deterministic actor tests for both interleavings (stale-reorg-discarded, stale-confirm-discarded). The lndclient adapter forwarders no longer inject a reorg-first bias.

This makes the consumer the authoritative ordering point, so the in-process lnd.go forwarder finding is covered too — no per-hop priority-drain needed.

Other findings

  • 🔴 notifyReorged before/without initial TxConfirmed (02b1f52): skip pendingConfirmed subscribers in notifyReorged — they get live state on their eventual reliable initial delivery.
  • 🟠 reversible delivery trailing terminal (02b1f52): added a per-entry sealed flag set when terminal delivery begins; each fire-and-forget reversible goroutine checks it immediately before its Tell and skips, so a reversible spawned just before finality can't resurrect released bookkeeping.
  • 🟠 registerBlocksForFinality blocking the select loop (b17729f): arming now runs in a short-lived goroutine that hands the registration back on a channel (or cancels it if the actor exits first), gated so at most one armer runs; the loop stays responsive to Reorged/Done/ctx.Done during the ~30s retry window.
  • 🟡 spend actor missing the single-shot guard (ce5ef78): added the reorgAware gate so a non-reorg-aware actor-mode spend watch exits after the first spend (matching ConfActor + the documented contract) and doesn't arm an unwanted block sub.
  • 🟡 conf-watch leak on terminal OnStop (02b1f52): terminal entries now unregister their watch too, guarded by confWatchRegistered.
  • 🟡 reorg-only watch torn down at finality (3eb6f61): documented as intended (past safety depth there's nothing left to observe).
  • 🟡 boarding-sweep MarkBoardingSweepFailed (3ae1bb5): added a no-op-when-already-resolved guard so a spurious TxFailed can't roll back a confirmed sweep's irreversible ledger legs.

The stale-lastEvent-after-reorg nit is folded into the seq commit (lastEvent is cleared on reorg).

Verification: new unit tests for the seq ordering (both interleavings) + the boarding-sweep guard; chainsource/txconfirm race suites, chainbackends/wallet suites all green; build/vet/fmt/lint-changed-local clean; commit messages lint-clean.

@claude

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review: reorg-safe chain observation (#422)

Reviewed the full diff with focus on the concurrency-heavy core (chainsource actors, txconfirm FSM, the chainbackends forwarders). Overall this is a careful, well-reasoned PR. The hardest parts hold up under scrutiny:

  • Highest-seq-wins ordering across the separate Confirmed/Reorged (and Spend/Reorged) channels is correct — the Seq != 0 && Seq <= lastSeq discard plus Seq == 0 always-apply for non-reorging backends is sound.
  • Finality watermark math (depth = epoch.Height - confirmHeight + 1, fire at >= FinalityDepth) has no off-by-one; the reset of confirmHeight/spendHeight to 0 and lastEvent to nil on reorg correctly restarts the window and avoids negative-depth wraparound.
  • Context lifetime: both actors derive long-lived work from a context.Background()-rooted ctx rather than the request ctx; the armFinalityAsync goroutine is wg-tracked and cancels its registration if the actor exits first. No leaks found in the steady-state lifecycle.
  • Forwarder rewrite (lnd.go): defer LIFO order (event.Cancelcancel → close chans) matches the documented intent; every blocking send is guarded by notifyCtx.Done(); nilling event.NegativeConf/Reorg/Done on close is the correct idiom (no busy-spin, no missed events).
  • boarding-sweep failure-ignore guard is correct: it only ignores TxFailed for terminal-success statuses (confirmed/external_resolved); reorg-out now arrives as TxReorged, so a TxFailed against an already-booked sweep is genuinely spurious, and the new default arm surfaces unknown statuses as a warn rather than dropping them.

No high- or medium-severity correctness bugs surfaced. The items below are low-severity robustness/observability nits.

Low — worth addressing

  1. txconfirm/actor.go:879 — duplicate Confirmed (no intervening reorg) doesn't refresh stored ConfirmHeight. The "already-Confirmed, re-Confirmed" branch re-delivers TxConfirmed with the new blockHeight but does not advance the FSM, so trackedTxStateConfirmed.ConfirmHeight keeps the original value. A later Done snapshots the stale height into TxFinalized, so the terminal event can carry an older height than the last TxConfirmed. The reorg→reconfirm path is fine (it re-advances the FSM); only the "duplicate Confirmed" path drifts. The branch already notes it's "unexpected on a well-behaved backend," so impact is limited — but either advance the FSM here or document that the height isn't refreshed.

  2. txconfirm/actor.go:2076terminalNotifyInflight key can leak permanently when selfRef == nil. The timeout branch sets the inflight key (line 2076) before calling completeTerminalNotifyAsync, which returns early without ever sending a terminalNotifyResultMsg if a.selfRef == nil. handleTerminalNotifyResult then never runs to delete the key, so notifyOneTerminal returns false forever for that kind+subscriber, wedging all future terminal deliveries to it. Guarded at admission today (handleEnsure ensures selfRef), so defense-in-depth — but cheap to make robust by deleting the key (or skipping the set) on the selfRef == nil path.

  3. chainsource/spend_actor.gospendHeight == 0 sentinel is overloaded. spendHeight doubles as "no active spend" (the Done-synthesis guard) and a real height. If any backend ever delivers a SpendDetail with SpendingHeight == 0, the spend is delivered but finality synthesis can never fire and the sub-actor leaks until Cancel. Not reachable with lnd/btcw today (they only emit confirmed spends, height > 0), but SpendDetail.SpendingHeight doesn't forbid 0 — a defensive comment or an assert in buildSpendEvent would close the gap. The conf path doesn't share this (a confirmed tx always has a real height).

Low — observability / nits

  1. chainsource/spend_actor.go monitorSpend is silent vs. monitorConfirmation. The conf monitor logs start/stop lifecycle and a WarnS when the subscription channel closes unexpectedly; the spend monitor has neither, and on subscription-close calls failSpend (a no-op in actor mode) with no log — an unexpected backend channel close on a spend watch is completely silent. Mirror the conf actor's WarnS + lifecycle logs for symmetry.

  2. chainbackends/lndclient_adapters.goDone channel allocated, never written, never closed. Harmless (the select arm just parks; finality is height-synthesized) and documented, but closing it on cancel() would let the forwarder exit one step sooner and removes the "goroutine selects forever on a never-ready channel" smell.

  3. chainbackends/lnd.goevent.Cancel() runs twice on the normal cancel path (the returned Cancel closure calls it, then the forwarder's deferred event.Cancel() runs after cancel() unblocks the select). Safe because both lnd chainntnfs cancels and the lndclient context.CancelFunc are idempotent — worth a one-line note recording that implicit dependency.

  4. txconfirm doc nit — TxFinalized.NumConfs is described two ways. The type-level doc says it replays "the authoritative confirmation numbers carried by the last TxConfirmed," but the implementation always passes entry.data.TargetConfs (matching the field-level comment). Align the type-level doc to the implementation.

Tests

Coverage is strong: forwarder reorg lifecycle tests, height-based Done synthesis, full txconfirm reorg round-trips, the Done-during-reorg-gap pin, and systest coverage for both pipelines. Consider adding a unit test for finding #1 (duplicate Confirmed height refresh) if you decide that path should advance the FSM.

Nice work on the documentation density in the forwarder and the FSM — the inline rationale (defer ordering, seq authority, buffer sizing) made this much easier to review.

🤖 Generated with Claude Code

@ellemouton

Copy link
Copy Markdown
Member Author

@claude please re-review. The 6 new commits (bc869df..3eb6f61c) address your second review pass:

  • The 🔴 cross-channel ordering hazard is now fixed at the root with seq-stamped lifecycle ordering (per-registration monotonic sequence stamped at the LNDBackend forwarder; conf/spend actors apply highest-seq-wins), replacing the reorg-first heuristic — correct for both reorg-then-reconfirm and confirm-then-reorg.
  • 🔴 notifyReorged now skips pendingConfirmed subscribers.
  • 🟠 per-entry sealed flag stops reversible deliveries trailing the terminal; finality arming moved off the select loop.
  • 🟡 spend-actor single-shot guard, terminal conf-watch unregister on stop, reorg-only-watch teardown doc, and the boarding-sweep already-resolved guard.

Please focus on the seq-ordering approach in 101168f (is highest-seq-wins sound given the forwarder is the single ordering point?) and the sealed-flag race window in 02b1f52.

@claude

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 5m 14s —— View job


Re-review: seq-ordering (101168f) + sealed-flag race (02b1f52)

Focused on the two areas you called out, plus a pass over the other four commits. Verdict up front: the seq scheme is sound for everything downstream of the forwarder, and the sealed flag closes the common case — but neither is fully airtight, and in both the residual gap reduces to one upstream assumption worth pinning down or documenting. Details below.


✅ Seq-ordering (101168f) — sound, with one load-bearing assumption

The highest-seq-wins logic itself is correct:

  • Monotonic per-registration counter stamped by the single forwarder goroutine, shared across Confirmed/Reorged (and Spend/Reorged). Both interleavings converge to the highest-seq outcome — verified by walking reorg→reconf and confirm→reorg through conf_actor.go:309-318 / :373-381 and the spend equivalents.
  • Seq == 0 always-apply preserves non-reorging-backend behavior. ✓
  • lastEvent = nil on reorg correctly prevents a later Done from reporting the reorged-out txid/outpoint, and deliverConfReorged/deliverConfDone are nil-safe (conf_actor.go:551-590 fall back to a.txid or zero). ✓
  • Transient intermediate states can now be skipped (e.g. conf(1), conf(3) applied, reorg(2) discarded) and the actor still lands in the correct final state — this is the right behavior, not a bug. ✓

The one thing the whole scheme rests on: the forwarder must observe lnd's events in true order. But the forwarder (lnd.go:354-390) assigns seq inside a select over event.Confirmed and event.NegativeConf — exactly the "two-ready-channels picks at random" construct the commit message says cannot recover order. So if the upstream ever has a NegativeConf and its superseding re-Confirmed buffered simultaneously at this select, the forwarder can stamp them in the wrong order, and highest-seq-wins will then faithfully honor the wrong order — reintroducing the original leak at the source.

This is safe iff lnd's chainntnfs (in-process) and the lndclient adapter (gRPC) dispatch a reorg and its replacement (re)confirmation with blocking, cap-1, serialized sends so the two can't sit buffered together when the forwarder runs its select. In practice that holds — lnd's TxNotifier processes block connect/disconnect sequentially with blocking sends, and forwardOrderedReorg is a single ordered receive loop — so this is almost certainly fine today. But the correctness of the entire fix now hinges on that upstream invariant, and nothing in lnd.go enforces or documents it. Recommend a comment at the forwarder select stating that seq stamping assumes the upstream never has a reorg and its superseding (re)confirmation buffered simultaneously here, and ideally an in-process LNDBackend test that buffers both before the forwarder drains (the existing lndclient_ordering_test.go only exercises the adapter, not this select).

Minor, non-blocking: in confirm→reorg with both buffered and the actor select picking Reorged first, the initial Confirmed is discarded (seq <= lastSeq) and the actor emits a bare ConfReorgedEvent with lastEvent == nil — a reorg-of-nothing. It's handled gracefully end-to-end (nil-safe delivery; txconfirm handleConfirmationReorged drops it via the state != TxStateConfirmed guard at actor.go:914), and the final state is correct (not confirmed). Worth a one-line note that a reorg can legitimately be delivered with no preceding confirmation under this ordering.


🟡 sealed-flag race (02b1f52) — narrows the window, doesn't fully close it

The flag closes the common case, but there's still a real window:

// reversible goroutine
if sealed != nil && sealed.Load() {   // (A) reads false
    return
}
subscriber.Tell(notifyCtx, notification)   // (C) lands in mailbox
// actor goroutine, notifyFinalized/notifyFailed
entry.sealed.Store(true)               // (B)
... notifyOneTerminal -> terminal Tell // (also lands in mailbox)

If the reversible goroutine passes the guard at (A), is preempted, then the actor runs (B) and delivers the terminal Tell, then the reversible resumes at (C) — the reversible Tell enqueues after the terminal one. Since Load() and the subsequent Tell are not atomic w.r.t. the actor's Store + terminal Tell, the mailbox can still end up [..., TxFinalized, TxReorged]. The terminal delivery via notifyOneTerminal (actor.go:2046) is itself a goroutine the actor waits on, so the two Tells are genuinely concurrent — the atomic orders the flag, not the two sends.

So the commit message's "cannot trail the terminal notification" is slightly too strong; it's "very unlikely to trail." Severity is low in practice:

  • the window is a handful of instructions between the Load and the Tell;
  • reversible delivery is documented best-effort; and
  • the one consumer that books irreversible state (boarding sweep) got the defensive already-resolved guard in 3ae1bb5.

But I'd either (a) soften the comment/commit claim to "narrows the window" and state that consumers must treat a reversible arriving after a terminal as a no-op, or (b) if you want it airtight, serialize reversible + terminal delivery per subscriber (single ordered path) rather than racing two goroutines. (a) seems proportionate given the semantics; just don't let the "cannot" claim imply a guarantee the code doesn't provide.


✅ Other four commits — all sound

  • notifyReorged skips pendingConfirmed (02b1f52) — correct; those subscribers get live state on their reliable initial delivery, so they can't see TxReorged before/without TxConfirmed. ✓
  • Spend-actor single-shot guard (ce5ef78)reorgAware := notifyReorged.IsSome() || notifyDone.IsSome() now mirrors ConfActor exactly (spend_actor.go), restoring the documented single-shot contract for fraud/unroll/boarding-sweep callers and avoiding an unwanted block-epoch sub. ✓
  • Terminal conf-watch unregister on OnStop (02b1f52) — terminal entries now release the watch guarded by confWatchRegistered, closing the Finalized/Failed-still-holding-a-watch leak. ✓
  • Finality arming off the select loop (b17729f)armFinalityAsync is wg-tracked, gated by the arming flag (≤1 armer), hands back on blockRegCh, and cancels the reg if the actor exits first. Loop stays responsive to Reorged/Done/ctx.Done during the bounded retry window. ✓
    • Low note: if the loop returns on a backend Done while an armer is mid-retry, the armer parks on select { regCh<-reg; <-ctx.Done() } holding a freshly-created BlockRegistration until a.ctx is cancelled (i.e. until the parent stops the actor). Temporary block-sub linger, not a permanent leak (wg.Wait + ctx-cancel reaps it). Only reachable on backends that actually emit Done (not lndclient). Minor.
  • Boarding-sweep already-resolved guard (3ae1bb5)isTerminalSuccessSweepStatus correctly gates only confirmed/external_resolved; a spurious TxFailed no longer rolls back irreversible txid-keyed ledger legs. ✓

Summary

Both focus items are correct in the common case and converge to the right state; the residual gaps are (1) the forwarder-select ordering assumption that now underpins the entire seq scheme — please document/test it — and (2) the sealed flag being a window-narrowing rather than a window-closing guard, which I'd reflect in the comment + a consumer-side no-op expectation. Neither blocks merge in my view, but the forwarder-select invariant is the one I'd most want pinned down since the whole fix depends on it.
· branch reorg-safe-chainsource

@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch from 3eb6f61 to 449b6cf Compare July 1, 2026 16:09
@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review: multi: Reorg-aware chainsource + chainbackends + txconfirm

Reviewed the full diff (31 files, ~5.3k additions). This is a large, unusually well-documented foundation PR — the invariants in the CLAUDE.md/AGENTS.md files and the inline commentary make the reorg lifecycle easy to follow, and the layering (observation-only, deferring VTXO/batch interpretation) is a clean split. The fire-and-forget-reversible vs. reliable-terminal notification split in txconfirm, the sealed guard, and the lease/reservation eviction on terminal states are all handled carefully.

A few things worth a closer look before merge.

1. (Potential correctness race) The seq/highest-seq-wins scheme is stamped at a hop that itself cannot observe true order

TxConfirmation.Seq / SpendDetail.Seq are documented as "stamped by the backend forwarder in the order it observed lifecycle events" (chainsource/backend.go:201), and the consumer applies highest-seq-wins to recover ordering across the split Confirmed/Reorged channels (chainsource/conf_actor.go:309-317, 371-384).

The problem: the forwarder that assigns seq also demultiplexes two channels with a select (chainbackends/lnd.go:355-388, seq++ in both the event.Confirmed and event.NegativeConf arms). A select over two simultaneously-ready channels picks at random — which is exactly the ambiguity seq is meant to eliminate. So seq faithfully records the forwarder's arbitrary select order, not lnd's causal order. Highest-seq-wins then latches whatever the forwarder happened to read last.

Concrete failure (reorg-then-reconfirm across a reorg block): lnd emits NegativeConf then re-Confirmed(H') causally. If both are buffered when the forwarder loops, the select can read Confirmed(H') first (seq=n) then NegativeConf (seq=n+1). The consumer applies Confirmed(H'), then the higher-seq Reorged → resets confirmHeight=0, lastEvent=nil, and delivers TxReorged. The tx is actually confirmed at H', but the watch is now stuck in AwaitingConfirmation, no further conf event will arrive (lnd already delivered it), and height-based Done synthesis is gated on confirmHeight != 0 (conf_actor.go:435) so it never fires either — the watch strands.

For the lndclient path this is more clearly reachable: forwardOrderedReorg (lndclient_adapters.go:274) goes to great lengths to serialize order through one goroutine, then hands off into two buffered channels (orderedConfirmed, negativeConf), which the LNDBackend forwarder immediately re-selects over — undoing that serialization. The adapter comment claims order is "re-established downstream by the per-registration sequence number," but the seq is stamped after the re-split, so it can't re-establish anything.

This is timing-dependent and I could not repro it in a unit test in the time I had, so please treat it as "verify" rather than confirmed — but I'd want either (a) the seq stamped at the single true-ordering point (inside forwardOrderedReorg / at the chainntnfs-observation boundary, carried through as a field rather than re-derived after the split), or (b) an explicit, documented argument for why the two upstream channels can never hold causally-ordered events simultaneously at the forwarder hop. As written the mechanism's correctness rests on an assumption the code structure doesn't actually enforce.

2. (Latent, currently masked) Broadcasting -> Confirmed carries a stale BroadcastFailures into the Confirmed state

states.go (Broadcasting handling of trackedTxConfirmed) copies s.trackedTxProgress into trackedTxStateConfirmed, so a direct Broadcasting→Confirmed edge inherits a non-zero BroadcastFailures. This is harmless today only because trackedTxBroadcastFailures (fsm_types.go) returns 0 for any non-Broadcasting state, so the stale value is never read. But it contradicts the documented "reset to zero once advanced past Broadcasting" invariant and becomes a live bug the moment anyone extends the accessor. Worth zeroing it on the transition (or a comment pinning the assumption).

3. Test coverage gaps

Coverage of the happy-path lifecycles is genuinely good (full conf/spend Confirmed→Reorged→Confirmed→Done, height-based Done synthesis with boundary, conf-side seq tie-breaking both directions, the Done-during-reorg-gap drop, systest round-trips through real bitcoind). The notable gaps:

  • finality.go retry/backoff is entirely untested. registerBlocksForFinality's bounded-backoff loop, per-attempt timeout, and non-fatal give-up (reg == nil handoff that re-clears arming so a later confirmation retries) have no coverage — mockBackend.RegisterBlocks always succeeds immediately. This is the single largest gap and it's exactly the lndclient path this PR targets.
  • Spend-side seq tie-breaking is untested. spend_actor.go:289-355 has the identical lastSeq discard logic as the conf side, but there's no TestSpendActorDiscardsStale...BySeq; the spend lifecycle test only sends Seq: 0.
  • The sealed guard (actor.go:1903) that drops a reversible Tell after terminal delivery begins is only incidentally reached — no test spawns a reversible, seals, and asserts the drop.
  • Reversible fan-out with a slow subscriber: blockingNotifyRef is used only for the terminal path; no test asserts the actor loop stays unblocked when a subscriber is slow during a TxReorged/re-TxConfirmed.
  • Height-counter reset + re-arm: no test drives Confirmed(h) → Reorged → Confirmed(h') → blocks → assert Done fires against h', not h (the load-bearing confirmHeight==0 reset + depth-underflow guard). Given finding lib: start adding lib helpers #1, a test that exercises reorg→reconfirm→synthesized-Done would be especially valuable.

Minor

  • LNDBackend.RegisterConf's reorgChan is buffered (8) but confChan is buffered 1; the asymmetry combined with blocking sends is what makes the finding-lib: start adding lib helpers #1 window reachable — worth a comment on the intended relationship if the design is kept.
  • unregisterConfWatch on a Finalized entry (OnStop / evict paths) can fire against a chainsource sub-actor that already exited on Done; benign if chainsource tolerates unknown-caller unregisters, but confirm that's the case.

Overall the structure is solid and the eventual-consistency framing is sound; my main ask is nailing down (or refuting) the ordering race in #1, since it sits under the whole reorg mechanism.

🤖 Generated with Claude Code

ellemouton added a commit that referenced this pull request Jul 1, 2026
Make confRegistration / spendRegistration multi-shot reorg-aware:

  - Add Reorged / Done channels alongside Confirmed / Spend on the
    returned chainsource.ConfRegistration and SpendRegistration so
    the backend now satisfies the multi-shot contract that PR #422
    introduced for the LND-backed backend.
  - Track a per-registration state (stateWatching | statePositive)
    plus the last-delivered block hash so the reorg handler can
    decide whether a given reorg invalidates a previous delivery.
  - Stop deleting registrations on the first positive event. A
    confirmation that survives a future reorg is now repeatedly
    re-deliverable on the same registration; the chainsource conf /
    spend actor synthesizes Done at FinalityDepth from block epochs.

Subscribe to the TipPoller's new ReorgSubscribe stream alongside the
existing TipBlock stream via BestBlockAndSubscribeAll. When a
ReorgEvent arrives, walk every active conf / spend registration and:

  - For each one in statePositive whose last block hash is in the
    disconnected set, fire Reorged (non-blocking; the channel is
    buffered to 1 and a coalesced reorg signal is semantically
    correct -- the consumer re-queries either way), reset state to
    stateWatching, and re-check status against the new chain so a
    re-confirmation / re-spend fires Confirmed / Spend immediately
    in the same handler turn.
  - For registrations whose last block hash was never resolved
    (typically a transient Esplora failure at delivery time) we
    leave them alone; the broad tip-driven re-check still runs and
    will catch up on the next block.

Cancel now uses a regMu-protected once-style check so a
double-Cancel is a safe no-op. Channel sends are performed outside
regMu so a slow consumer never blocks the broad re-check goroutine.
@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review: multi: Reorg-aware chainsource + chainbackends + txconfirm

Thorough, well-documented foundation work. The lifecycle model (Confirmed → Reorged → Confirmed → Done, and the TxConfirmed/TxReorged/TxFinalized equivalent) is coherent, the FSM changes are clean, and the reasoning captured in comments and the updated CLAUDE.md/AGENTS.md files is excellent — it made this large diff reviewable. Test coverage (unit + systest for both the chainsource actors and the txconfirm FSM) is strong. The last commit fixing the finality block-sub ctx wrapping was the right call.

Feedback below, roughly ordered by significance. Most of it is questions/observations rather than blockers, since the concurrency design is clearly deliberate.

1. lndclient two-hop ordering: does the Seq mitigation actually cover a tight reorg+reconfirm? (correctness)

The stated design is: Confirmed and Reorged travel on separate channels, ordering is unrecoverable in a select, so the LNDBackend forwarder is the single authoritative ordering point and stamps a monotonic Seq; downstream applies highest-seq-wins.

For the in-process lnd path that holds. For the lndclient/gRPC path — the one the PR says is the practical production transport — there are two merge points:

  • forwardOrderedReorg (lndclient_adapters.go) merges reorgPing + confChan into orderedConfirmed / negativeConf (each buffered size 1) with a plain, unbiased select, handing off with buffered sends that return before the consumer reads them.
  • LNDBackend.RegisterConf's forwarder (lnd.go) then reads those two channels with another plain select and stamps Seq there.

Because forwardOrderedReorg returns from its buffered send without waiting for LNDBackend to consume, it can place a reorg into negativeConf and a re-confirm into orderedConfirmed so both buffers are occupied simultaneously. LNDBackend's select then picks at random, so Seq is stamped in whatever order that select fires — not lnd's true emission order. Concretely:

  • True order (reorg, then re-confirm) → correct final state = Confirmed.
  • Scrambled at LNDBackend: re-confirm read first (Seq=N), reorg read second (Seq=N+1). The ConfActor applies the confirm, then applies the reorg (Seq=N+1 > N), resetting confirmHeight=0 and lastEvent=nil. The re-confirmation was already consumed as the lower seq, so no further Confirmed arrives, height-based Done never fires (it's gated on confirmHeight != 0), and the watch is stranded in reorged-out state until cancel.

During a reorg where the tx re-confirms in the replacement block, lnd emits the disconnect (reorg ping) and the new confirmation essentially back-to-back off one block-notification batch, so the "both buffers occupied" window looks reachable rather than purely theoretical.

The Seq scheme only reconstructs order if it is stamped before any cross-channel merge — but here the last merge (LNDBackend) is where it's stamped, and that merge can already reorder relative to forwardOrderedReorg's output. Could you confirm whether temporal separation is actually guaranteed, or whether forwardOrderedReorg needs to stamp the sequence itself (become the single ordering point for the gRPC transport) rather than deferring to a downstream select? Tuning the systest to mine the reorg+reconfirm within one notification batch would be a good stress case.

2. Contradictory comments on forwardOrderedReorg / forwardOrderedSpendReorg (docs)

The block comment above the goroutine that calls forwardOrderedReorg says "Draining a pending reorg with priority on each iteration, and handing every event off with a blocking send, makes the ConfActor observe exactly the forwarder's (lndclient's) order." But forwardOrderedReorg does a plain unbiased select (no reorg-priority drain), and its own doc comment explicitly says "It does not bias either channel". One of these is stale and they point in opposite directions — worth reconciling, especially since it's load-bearing reasoning for #1. Same duplication on the spend path.

3. notifyReversibleAsync seal check is not atomic with the Tell (minor / by-design)

The sealed.Load() check runs on the delivery goroutine "just before the Tell," but the actor can Store(true) in the window between that check and subscriber.Tell(...), so a reversible TxReorged/TxConfirmed can still narrowly trail a terminal TxFinalized/TxFailed into a subscriber's mailbox. Acceptable given reversibles are best-effort and consumers are documented idempotent (boarding-sweep is), but it's a residual race rather than an eliminated one — worth a one-line note so a future reader doesn't assume the seal is a hard barrier.

4. Redundant unregisterConfWatch in handleConfirmationDone (nit)

It calls unregisterConfWatch(...) then evictTerminal(...), and evictTerminal already unregisters when confWatchRegistered is true. The first call flips the flag so the second is skipped — harmless, but dead weight that obscures that eviction owns teardown.

5. Spend-watch path is not reorg-symmetric (already acknowledged)

The boarding-sweep comment is candid that handleSweepSpendNotification's MapSpendEvent collapses the spend lifecycle, so a reorged-out external spender leaves the input row external_spent until manual reconciliation. Flagging that this asymmetry (conf watches reorg-aware, per-input spend watches not) is a real gap in the reorg-safety story for this PR's own consumer — good that it's called out and tracked, but the boarding-sweep flow is only half-covered.

Minor

  • Height-based Done for a tx already buried far below FinalityDepth at registration won't fire until the next block epoch arrives (synthesis only evaluates on epochs, and the sub arms after the first confirmation). Correctness is fine; just up-to-one-block-interval latency on TxFinalized for already-deep txs.
  • reorgSignalBufferSize = 8 with a blocking send: a reorg deeper than 8 disconnected blocks emitting >8 NegativeConf back-to-back would head-of-line block the forwarder on that channel. Benign per the coalescing rationale, but the bound is silent if it ever mattered.

Nice work overall — the hard part (getting the lifecycle and the finality-synthesis fallback right) is in good shape. #1 is the one I'd most like to see confirmed before this lands.

@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 from ded7b90 to 32871de Compare July 8, 2026 20:41
Add the reorg-aware chain-observation substrate the rest of the
reorg-safety stack consumes:

  - chainsource: reorg-aware, seq-ordered conf/spend watch lifecycle
    (Confirmed -> Reorged -> re-Confirmed -> Done) with height-based
    finality synthesis for backends that cannot deliver Done, armed
    off the select loop and tied to the sub-actor's long-lived ctx.
  - chainbackends: forward lnd and lndclient reorg/finality signals
    (NegativeConf -> reorg) with buffered forwarding.
  - txconfirm: reorg-aware confirmation watches delivering the full
    TxConfirmed/TxReorged/re-TxConfirmed/TxFinalized/TxFailed cycle,
    a terminal seal guarding reversible fire-and-forget deliveries,
    and a catch-up TxReorged when a reorg lands while a subscriber's
    initial TxConfirmed is still parked on the async notify path.
  - wallet: make the boarding sweep reorg-aware.
  - darepod: enable height-based finality on the chainsource actor.
  - harness: GetRawTransaction and SignedV3Tx helpers; end-to-end
    reorg tests for chainsource and txconfirm.

Squashed and ported to the btcd v2 module set for the rebase onto
main.
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch from 32871de to 4a1f9b1 Compare July 8, 2026 20:41
@claude

claude Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review: Reorg-aware chainsource + chainbackends + txconfirm

Thorough, well-documented foundation PR. The design rationale in the comments and the CLAUDE.md/AGENTS.md updates are genuinely excellent — the sequence-ordering scheme, the off-select-loop finality arming, the terminal-seal guard for fire-and-forget reversibles, and the "never give up on a no-mempool tx" contract are all clearly reasoned. Test coverage (unit + systest for both actors and the full txconfirm lifecycle) is strong.

A few things worth a second look before merge.

1. Contradictory ordering comments in lndclient_adapters.go (should fix)

The call-site comment in RegisterConfirmationsNtfn (≈chainbackends/lndclient_adapters.go:227-232) states:

Draining a pending reorg with priority on each iteration, and handing every event off with a blocking send, makes the ConfActor observe exactly the forwarder's (lndclient's) order.

But forwardOrderedReorg (:274-311) does neither — its own doc (:261-273) explicitly says it "does not bias either channel" and forwards in "natural arrival order", and the two hand-off channels are buffered (orderedConfirmed/negativeConf, cap 1), so the "blocking send" does not actually serialize the two events. The same stale claim appears on the spend path (:379-386). These read like a comment left over from an earlier priority-draining implementation. Please reconcile them — the ordering guarantees here are safety-critical and a wrong comment will mislead the next maintainer.

2. Seq stamping can invert a near-simultaneous reorg→reconfirm (worth confirming)

Related to #1: the authoritative sequence numbers are stamped by the LNDBackend forwarder's select over event.Confirmed / event.NegativeConf (chainbackends/lnd.go:379-432), not at the single ordered upstream (lnd's notifier / lndclient's gRPC loop). Because both downstream channels are buffered (cap 1 / cap 8), if a reorg-out and the replacement confirmation are both queued when the forwarder runs its select, Go picks at random and can stamp Confirmed=seq N, Reorged=seq N+1. The consumer's highest-seq-wins logic (conf_actor.go:312-317,379-384) then resolves the entry to reorged-out when the truth is re-confirmed.

In the common case reorg and reconfirmation are block-separated, so the buffers never hold both at once and this can't trigger. But a reorg that re-mines the tx at a new height in the same notifier processing cycle is exactly the case where both can be buffered together. If you've already convinced yourself this can't happen with the current backends, a one-line note on TxConfirmation.Seq explaining why temporal separation is guaranteed would make the invariant defensible; otherwise the seq should be stamped at the single ordered source.

3. Reorg-aware actor watch is silent to its subscriber on unexpected channel close (minor)

In ConfActor.monitorConfirmation (chainsource/conf_actor.go:293-307) and the spend equivalent, a closed/nil Confirmed/Spend channel calls failConfirmation, which is a no-op in actor mode (no promise). For a reorg-aware watch this means the sub-actor exits without ever telling NotifyReorged/NotifyDone, so the txconfirm entry is stranded waiting for a Done that will never come (it only re-registers on eviction). This is fine on clean shutdown, but an unexpected backend channel close mid-life leaves the tracked tx wedged. Consider surfacing a terminal signal (or at least a WARN with enough context) so the upper layer can react rather than silently hang.

4. Minor / nits

  • Context handling asymmetry: RegisterConf/RegisterSpend deliberately detach the forwarder onto a fresh context.Background()-derived notifyCtx (lnd.go:345,469), but RegisterBlocks (:572-636) forwards on the caller ctx directly. It's safe here because finality arming passes the sub-actor's long-lived a.ctx, but the inconsistency invites a future caller to pass a request-scoped ctx and get a prematurely torn-down block feed. A comment noting the contract would help.
  • Known gap acknowledged: the spend-watch reorg asymmetry called out in wallet/boarding_sweep_actor.go:1197-1205 (a reorged-out external spender leaves the input external_spent until manual reconciliation) is documented as tracked separately — just flagging it's a real hole the follow-up stack needs to close.

Style / conventions

Consistent with the repo guide — 80-col, name-prefixed comments, structured logging with slog/btclog helpers, early returns, terminal-only error level. No generated-code or raw-SQL violations spotted.

Nice work — the substance is solid; #1 is the only thing I'd consider blocking, and #2 is worth an explicit answer in the thread.

@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
ellemouton added a commit that referenced this pull request Jul 9, 2026
The finality-synthesis block-epoch subscription must be armed with the
sub-actor's long-lived context, handed to RegisterBlocks unwrapped. A
per-attempt context.WithTimeout+cancel tears the subscription down the
instant it is armed (the in-process block-epoch forwarder is tied to the
ctx it receives), so no Done is ever synthesized and reorg-aware
confirmations never finalize -- rounds/exits sit provisional forever
even as the chain buries them far past the reorg-safety depth.

This restores the reviewed #422 behavior that a sibling branch's stale
copy of finality.go had reverted; it was reintroduced here when the
reorg stack was condensed. Regression-caught by the round-never-confirmed
itest/systest failures.
ellemouton added a commit that referenced this pull request Jul 9, 2026
The finality-synthesis block-epoch subscription must be armed with the
sub-actor's long-lived context, handed to RegisterBlocks unwrapped. A
per-attempt context.WithTimeout+cancel tears the subscription down the
instant it is armed (the in-process block-epoch forwarder is tied to the
ctx it receives), so no Done is ever synthesized and reorg-aware
confirmations never finalize -- rounds/exits sit provisional forever
even as the chain buries them far past the reorg-safety depth.

This restores the reviewed #422 behavior that a sibling branch's stale
copy of finality.go had reverted; it was reintroduced here when the
reorg stack was condensed. Regression-caught by the round-never-confirmed
itest/systest failures.
ellemouton added a commit that referenced this pull request Jul 9, 2026
The finality-synthesis block-epoch subscription must be armed with the
sub-actor's long-lived context, handed to RegisterBlocks unwrapped. A
per-attempt context.WithTimeout+cancel tears the subscription down the
instant it is armed (the in-process block-epoch forwarder is tied to the
ctx it receives), so no Done is ever synthesized and reorg-aware
confirmations never finalize -- rounds and exits sit provisional forever
even as the chain buries them past the reorg-safety depth.

This restores the reviewed #422 behavior that a sibling branch's stale
copy of finality.go had reverted; it was reintroduced when the reorg
stack was condensed. Regression-caught by the round-never-confirmed
itest and systest failures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude-review 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.

4 participants