Skip to content

multi: reorg-aware lwwallet/Esplora chain backend (C0b) - #461

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

multi: reorg-aware lwwallet/Esplora chain backend (C0b)#461
ellemouton wants to merge 1 commit into
reorg-safe-chainsourcefrom
c0b-lwwallet-reorg-aware

Conversation

@ellemouton

@ellemouton ellemouton commented May 15, 2026

Copy link
Copy Markdown
Member

Summary

Makes the lwwallet/Esplora-backed `chainsource.ChainBackend` satisfy the
same reorg-aware contract the LND-backed adapter ships in #422:

  • `ConfRegistration` lifecycle: `Confirmed → Reorged → Confirmed → Done`.
  • `SpendRegistration` lifecycle: `Spend → Reorged → Spend → Done`.
  • `Done` is synthesized at the chainsource actor layer from block epochs;
    the backend keeps registrations alive past first positive event.

Without this, any darepod running on the lightweight wallet backend was
silently reorg-blind below the chainsource layer — meaning all the
reorg-handling work in #410 (unroll rollback) and the extras stack
(round Option B, boarding-sweep lifecycle, fraud invariants) would never
observe a reorg event for lwwallet deployments.

This is workstream C0b from epic lightninglabs/darepo#454. Branched
off `reorg-safe-chainsource` (#422), independent of #410 and the extras
stack — can land in parallel.

Changes

  • `tip_poller.go`: bounded `height → hash` ring buffer
    (`DefaultHashHistorySize = 100`), sibling `EventServer[*ReorgEvent]`
    reachable via `SubscribeReorgs` / `BestBlockAndSubscribeAll`. Three-case
    poll cycle catches same-height hash drift via `GetBlockHashByHeight` at
    the current tip and deeper reorgs via raw-header `PrevBlock` continuity
    on the first new height.
  • `chain_backend.go`: per-registration `stateWatching ↔ statePositive`
    state machine with cached last block hash. Reorg handler matches
    cached hash against `ReorgEvent.Disconnected`, fires `Reorged`
    non-blocking, resets state, and re-checks so a re-confirmation /
    re-spend can fire in the same handler turn. `Cancel()` is idempotent.
  • `chain_backend_reorg_test.go`: 7 new tests covering same-height drift,
    conf reorg round-trip, conf reorg with eviction (no re-confirm), spend
    reorg round-trip, deeper multi-height reorg, cancel cleanup with
    goroutine accounting, and registration-stays-alive after first event.
  • `AGENTS.md` / `CLAUDE.md`: removed the "same-height reorgs invisible"
    invariant; documented the new reorg-aware contract.

Test plan

  • `go test -race ./lwwallet/... ./chainsource/... ./txconfirm/... ./chainbackends/...`
  • Each commit builds and `go vet`s clean in isolation.
  • `make lint-native` clean (0 issues).
  • `make fmt-changed-check` clean.
  • `make commitmsg-lint` clean across all 4 commits.

Out of scope

  • BatchCanonicalityManager (C2/C3 — separate workstream).
  • Server-side batchwatcher (S1/S2 — separate epic).
  • Probe-vs-registration API split (deliberately deferred).
  • `SpendDetail.SpendingBlockHash` chainsource public API addition
    (worked around with an internal cache; possible follow-up).

Related

@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 comprehensive chain reorganization detection and handling. The TipPoller now maintains a hash history to identify same-height and deep reorgs, while the ChainBackend introduces a state machine for registrations to handle Reorged events and re-confirmations. Review feedback highlights an inefficiency in the TipPoller regarding redundant sorting and locking when building disconnected block lists, and suggests optimizing the ChainBackend by avoiding an extra HTTP call when resolving spending block hashes.

Comment thread lwwallet/tip_poller.go
Comment thread lwwallet/chain_backend.go Outdated
@ellemouton
ellemouton force-pushed the c0b-lwwallet-reorg-aware branch 2 times, most recently from e1be3be to 37be657 Compare May 16, 2026 06:56
@ellemouton
ellemouton force-pushed the c0b-lwwallet-reorg-aware branch from 37be657 to 631a652 Compare May 18, 2026 16:34
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch from aebb4c7 to 3877221 Compare May 27, 2026 23:39
@ellemouton
ellemouton force-pushed the c0b-lwwallet-reorg-aware branch 2 times, most recently from 2302eb0 to de446ec Compare May 27, 2026 23:50
@levmi levmi added the P1 Priority 1 — high label Jun 10, 2026
@levmi

levmi commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Triage linkage check — verified this PR fully implements all three of #128's proposed fixes (poll-loop reorg detect, re-evaluate fulfilled registrations, BlockDisconnected), each with test coverage. Note the current - Closes: lightninglabs/darepo-client#128 line won't trigger auto-close — the Closes: (with colon) form isn't a valid keyword — so #128 won't close on merge as written.

Suggestions:

@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch 3 times, most recently from ca29548 to a9ee5e8 Compare June 23, 2026 17:15
@ellemouton
ellemouton force-pushed the c0b-lwwallet-reorg-aware branch from de446ec to d16583b Compare June 23, 2026 17:33
Comment thread lwwallet/tip_poller.go
// from ForkHeight+1 onward, also in canonical order. Either slice may
// be empty: a same-height hash-replacement reorg has Disconnected of
// length 1 and Connected of length 1.
type ReorgEvent struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍

Comment thread lwwallet/tip_poller.go
// it; chainsource finality synthesis requires it too).
type ChainEvent struct {
Reorg *ReorgEvent
Tip *TipBlock

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are these fields always preseent? If so, can ditch the pointer perhaps.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Or is it more of an either/fn situation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Kept the two-pointer struct here for now. The "exactly one of Reorg/Tip is non-nil per event" invariant is documented and the producer/consumer ordering is unit-tested, and the only two consumers (ChainBackend + EsploraChainService) sit on the load-bearing reorg-before-tip ordering path, so I'd rather not churn it inside this PR. Happy to convert it to an fn.Either[*ReorgEvent, *TipBlock] in a focused follow-up if you'd prefer the type-enforced version.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Re: the either/fn idea — agreed that's the cleaner encoding of the discriminated union. Holding off in this PR for the reason above (touches the ordering-critical consumers); will do the fn.Either conversion as a standalone follow-up.

Comment thread lwwallet/tip_poller.go
}

t.mu.Lock()
t.recordHashLocked(h, liveHash)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can use the lnd wrapepr around sync map here (type safe sync map)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

recentHashes is guarded by t.mu together with tipHeight/tipHash/tipTime and is mutated via a range-and-prune under that same lock, so a standalone typed sync.Map would split the tip state across two synchronization domains and complicate the prune. Kept it as a mutex-guarded map for that reason.

Comment thread lwwallet/tip_poller.go
fmt.Errorf("subscribe to reorg events: %w", err)
}

return t.tipHeight, t.tipHash, t.tipTime, sub, reorgSub, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can return the first 3 vars as an independent struct

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed it'd read better. The (height, hash, time) triple recurs across BestBlock / BestBlockAndSubscribe / …All / …Chain plus several cross-package callers, so introducing a BestBlock struct ripples fairly wide. Left it out to keep this PR focused; can do it as a standalone cleanup.

Comment thread lwwallet/tip_poller.go
// more than historySize below the current tip. The buffer lets
// reorg detection walk back to the fork point using the cached
// hashes rather than re-fetching the entire pre-fork chain.
recentHashes map[int32]chainhash.Hash

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, not really a ring buffer persay. For that it'd be a fixed array with an index mod pointer into the array. You'd take a height, then map that relative to the array.

Just food for thought re strict semanatics

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — it's a prune-on-insert bounded map, not a ring buffer. Renamed the comments (and the CLAUDE.md reference) to "bounded history map".

Comment thread lwwallet/tip_poller.go
// regression by a future reader.
if newHeight <= oldHeight {
switch {
case newHeight < oldHeight:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Or it's a re-org that results in a shorter, but higher work chain.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. poll() now compares the live hash at the reported height against the cached one when the remote tip is lower, so a reorg onto a shorter higher-work chain is routed through handleReorg instead of being treated as transient lag.

While adding coverage I also found and fixed a latent walk-back panic: on a forward reorg whose new tip extends past the cached tip, the old code mistook the first uncached height for the fork point and underflowed the Disconnected slice cap (makeslice panic in the poll loop). Added unit tests for all four scenarios (shorter, same-height, deeper-forward, transient-lag).

Comment thread lwwallet/tip_poller.go
// as no-op and re-check on the next tick.
return

case newHeight == oldHeight:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should test this behavior and the above against a live Eslpora instane

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should also be able to cover all the scenarios as we envision with unit tests here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Left the live-Esplora exercise as a manual/integration follow-up — it needs a real endpoint plus a controllable reorg, so it doesn't fit the unit suite. Covered the logic with deterministic unit tests against the stubbed chain instead (see below).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — added TipPoller-level reorg unit tests covering the shorter higher-work chain, same-height replacement, deeper forward reorg, and the transient-lag no-op. There were none before.

Comment thread lwwallet/chain_backend.go Outdated
// would not appear here. Re-query canonical status and
// compare against the cached block hash; if the tx is
// no longer confirmed in that block, treat as reorg.
current := b.checkSingleConf(reg, currentHeight)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So if this return nil, then we fall thru thinking it's a re-org IIUC. I think we want to thread thru a proper error here or diff enum state (or fn.result) to be able to distinguish this state.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. Added confirmedBlockHash, a tri-state lookup: Some(hash) when the tx is still confirmed (independent of numConfs), None when definitively unconfirmed, and a non-nil error when canonical status can't be determined right now. reorgConfReg now only fires Reorged on a definitive negative or a divergent block hash; on a transient backend error it leaves the registration in statePositive so a later tip/reorg event re-evaluates, instead of firing a spurious reorg.

@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch 2 times, most recently from c86210e to 1ab3019 Compare June 29, 2026 15:05
@ellemouton
ellemouton force-pushed the c0b-lwwallet-reorg-aware branch from 521a9b4 to a50ba18 Compare June 29, 2026 15:28
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch from 3eb6f61 to 449b6cf Compare July 1, 2026 16:09
@ellemouton
ellemouton force-pushed the c0b-lwwallet-reorg-aware branch 2 times, most recently from 3743983 to d5d1232 Compare July 1, 2026 16:21
@litbot-9000

Copy link
Copy Markdown
Collaborator

@ellemouton, remember to re-request review from reviewers when ready

@levmi levmi added reorg safety Fund-safety: stuck, lost, or mis-counted funds labels Jul 6, 2026
@ellemouton
ellemouton force-pushed the reorg-safe-chainsource branch 2 times, most recently from 32871de to 4a1f9b1 Compare July 8, 2026 20:41
Squashed for the btcd v2 port; reorg-aware lwwallet/Esplora backend:
TipPoller same-height + deeper reorg detection via PrevBlock
continuity, unified ChainEvent stream, and BlockDisconnected
emission to btcwallet before connecting the replacement tip.
@ellemouton
ellemouton force-pushed the c0b-lwwallet-reorg-aware branch from d5d1232 to c5877aa Compare July 8, 2026 20:45
@ellemouton

Copy link
Copy Markdown
Member Author

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

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants