multi: reorg-aware lwwallet/Esplora chain backend (C0b) - #461
Conversation
There was a problem hiding this comment.
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.
e1be3be to
37be657
Compare
37be657 to
631a652
Compare
aebb4c7 to
3877221
Compare
2302eb0 to
de446ec
Compare
|
Triage linkage check — verified this PR fully implements all three of #128's proposed fixes (poll-loop reorg detect, re-evaluate fulfilled registrations, Suggestions:
|
ca29548 to
a9ee5e8
Compare
de446ec to
d16583b
Compare
| // 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 { |
| // it; chainsource finality synthesis requires it too). | ||
| type ChainEvent struct { | ||
| Reorg *ReorgEvent | ||
| Tip *TipBlock |
There was a problem hiding this comment.
Are these fields always preseent? If so, can ditch the pointer perhaps.
There was a problem hiding this comment.
Or is it more of an either/fn situation?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| t.mu.Lock() | ||
| t.recordHashLocked(h, liveHash) |
There was a problem hiding this comment.
Can use the lnd wrapepr around sync map here (type safe sync map)
There was a problem hiding this comment.
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.
| fmt.Errorf("subscribe to reorg events: %w", err) | ||
| } | ||
|
|
||
| return t.tipHeight, t.tipHash, t.tipTime, sub, reorgSub, nil |
There was a problem hiding this comment.
Can return the first 3 vars as an independent struct
There was a problem hiding this comment.
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.
| // 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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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".
| // regression by a future reader. | ||
| if newHeight <= oldHeight { | ||
| switch { | ||
| case newHeight < oldHeight: |
There was a problem hiding this comment.
Or it's a re-org that results in a shorter, but higher work chain.
There was a problem hiding this comment.
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).
| // as no-op and re-check on the next tick. | ||
| return | ||
|
|
||
| case newHeight == oldHeight: |
There was a problem hiding this comment.
Should test this behavior and the above against a live Eslpora instane
There was a problem hiding this comment.
We should also be able to cover all the scenarios as we envision with unit tests here
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
| // 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
c86210e to
1ab3019
Compare
521a9b4 to
a50ba18
Compare
3eb6f61 to
449b6cf
Compare
3743983 to
d5d1232
Compare
|
@ellemouton, remember to re-request review from reviewers when ready |
32871de to
4a1f9b1
Compare
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.
d5d1232 to
c5877aa
Compare
Summary
Makes the lwwallet/Esplora-backed `chainsource.ChainBackend` satisfy the
same reorg-aware contract the LND-backed adapter ships in #422:
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
(`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.
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.
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.
invariant; documented the new reorg-aware contract.
Test plan
Out of scope
(worked around with an internal cache; possible follow-up).
Related