Skip to content

multi: batch canonicality + VTXO admission gates - #896

Closed
ellemouton wants to merge 18 commits into
reorg-observefrom
reorg-canonicality-gates
Closed

multi: batch canonicality + VTXO admission gates#896
ellemouton wants to merge 18 commits into
reorg-observefrom
reorg-canonicality-gates

Conversation

@ellemouton

Copy link
Copy Markdown
Member

Part 2 of 3 of the condensed reorg-safety client stack (epic
lightninglabs/darepo#454). This is the interpret + gate layer.

⚠️ Stacked on #895 — review/merge that first. The diff is against
reorg-observe.

Turns raw reorg observation into a durable per-batch canonicality verdict and
gates VTXO admission on it:

  • batchcanon data model (reorg-reversible State, effective-expiry
    recompute, provisional-consumer edges) + the BatchCanonicalityManager
    actor (the sole client-side interpreter).
  • VTXO lineage availability derivation + the coin-selection / forfeit
    admission gate, wired across every VTXO source: round-born, OOR-received
    (multi-parent lineage), and unroll source-lineage — then activated live in
    darepod.

Commits (unchanged, re-grouped): C2 data model → C3/C4 manager → C5 vtxo
availability + gate → C6 round → C7 OOR multi-parent → C8 unroll lineage →
C9 darepod activation (7 commits).

Supersedes #794, #795, #796, #818, #819, #820, #822 (condensed here).

Stack:

  1. multi: reorg-aware chain observation (chainsource + backends) #895 — reorg-aware chain observation
  2. this PR — batch canonicality + VTXO admission gates
  3. unroll + round-finality/sweep/fraud reorg safety

@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 introduces a batch canonicality data model and manager to handle reorg-safety for batch (commitment) transactions. It adds a new batchcanon package to track batch states (e.g., Provisional, Finalized, ReorgedOut, ConflictFinalized) and a BatchCanonicalityPersistenceStore for durable storage. The RoundClientActor and VTXOManager are updated to integrate with this gate, ensuring that VTXOs are excluded from coin selection if their batch lineage is not canonical. My review identified a critical synchronization issue in the VTXO manager's forfeit restore logic, a potential data loss scenario in the batch canonicality manager's restore retry logic, and a memory leak in the round actor's confirmation cache.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread vtxo/manager.go
Comment on lines +981 to +987
if _, ok := m.actors[req.Outpoint]; ok {
m.logger(ctx).DebugS(ctx, "Forfeited VTXO already has a live "+
"actor; skipping restore",
slog.String("outpoint", req.Outpoint.String()))

return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

If UpdateVTXOStatus fails on the first attempt, the function returns an error, but the spawned actor remains in m.actors.

On a subsequent retry, the check _, ok := m.actors[req.Outpoint] succeeds, causing the function to return fn.Ok with Restored: false without ever updating the DB status to VTXOStatusLive.

This leaves the VTXO in VTXOStatusForfeited in the database. Upon daemon restart, the VTXO will not be loaded as live, resulting in a permanent loss of funds.

To fix this, if the actor is already resident but the DB status is still Forfeited, we should still execute UpdateVTXOStatus(ctx, req.Outpoint, VTXOStatusLive) to ensure the database is synchronized with the in-memory state.

Suggested change
if _, ok := m.actors[req.Outpoint]; ok {
m.logger(ctx).DebugS(ctx, "Forfeited VTXO already has a live "+
"actor; skipping restore",
slog.String("outpoint", req.Outpoint.String()))
return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{})
}
if _, ok := m.actors[req.Outpoint]; ok {
m.logger(ctx).DebugS(ctx, "Forfeited VTXO already has a live "+
"actor; ensuring DB status is updated",
slog.String("outpoint", req.Outpoint.String()))
if err := m.cfg.Store.UpdateVTXOStatus(
ctx, req.Outpoint, VTXOStatusLive,
); err != nil {
return fn.Err[ManagerResp](
fmt.Errorf("restore forfeited vtxo status: %w", err),
)
}
return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{Restored: true})
}

Comment thread batchcanon/manager.go
Comment on lines +708 to +719
for _, op := range consumed {
if err := m.cfg.RestoreConsumedVTXO(ctx, op); err != nil {
m.logger(ctx).WarnS(ctx, "Failed to restore forfeited "+
"VTXO after batch invalidation", err,
slog.String("batch", txid.String()),
slog.String("vtxo", op.String()))

// Keep the edges so the restore can be retried; do not
// drop them on partial failure.
return
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

If RestoreConsumedVTXO fails for any of the outpoints, restoreProvisionalConsumers returns early. While this keeps the remaining edges in the database, the batch's state has already been updated to StateConflictFinalized in both the database and the in-memory w.persisted field.

Because w.persisted is now StateConflictFinalized, any future events for this batch will derive StateConflictFinalized, matching w.persisted, and deriveAndPersist will return early without calling handleConsumerLifecycle or restoreProvisionalConsumers again. Furthermore, since StateConflictFinalized is considered a final state, it is not loaded or watched during Reconcile on startup.

This means any failed restore will never be retried, leaving the forfeited VTXOs permanently locked/forfeited and the provisional consumer edges orphaned in the database.

Consider implementing a retry mechanism, or scanning for and restoring any remaining provisional consumers in the database during startup/reconciliation.

Comment thread round/actor.go

return fn.Ok[actormsg.RoundActorResp](nil)
}
delete(a.pendingCommitmentConfs, event.Txid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The pendingCommitmentConfs map caches confirmation events for tracked commitment transactions. While entries are deleted upon successful finalization in handleCommitmentFinalized, they are never cleaned up if a round fails, aborts, or times out. This can lead to a gradual memory leak of cached confirmation events over the lifetime of the daemon.

Ensure that pendingCommitmentConfs is also cleaned up whenever a round is removed or cleaned up from the actor's tracking maps (e.g., during round failure or abort).

@ellemouton
ellemouton force-pushed the reorg-canonicality-gates branch 2 times, most recently from 4a5959f to 94b2927 Compare July 9, 2026 16:26
@ellemouton
ellemouton force-pushed the reorg-canonicality-gates branch 2 times, most recently from 11c74f1 to 4a2b582 Compare July 9, 2026 20:40
@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 3m 37s —— View job


Claude PR Review — batch canonicality + VTXO admission gates

I reviewed the diff against origin/reorg-observe. This is a well-structured layer: the observation → interpretation → action split is clean, the batchcanon package is genuinely behavior-free data+state, and the admission gate is permissive-by-default (unseen/unregistered lineage never blocks), which makes it safe to enable ahead of full producer coverage. Docs (CLAUDE.md/AGENTS.md) and test coverage (unit + systest across reorg/conflict/ancestor/multiroot/forfeit-restore) are strong.

I focused on correctness of the reorg-reversal paths and the gate wiring. I confirm all three findings from the automated review and add two of my own.


Confirmed findings

1. vtxo/manager.go:981-987 — forfeit-restore leaves DB stuck in Forfeited after a transient write failure (high).
The actor is inserted into m.actors at line 1002 before UpdateVTXOStatus at line 1004. If that write fails, the function returns an error, but the resident actor now trips the early-return guard at 981 on any in-process re-delivery, returning Restored:false without ever reconciling the DB. The in-memory actor is Live while the row stays Forfeited; on the next restart the live actor is gone (forfeited VTXOs aren't recovered) and the row keeps it forfeited. The self-healing claim in the comment at 989-993 ("re-driven by the persisted Forfeited status") only holds across a restart with an empty m.actors — it does not hold for the resident-actor guard. Gemini's suggested fix (reconcile the DB status inside the guard branch) is the right shape. Fix this →

2. batchcanon/manager.go:686-727 — failed provisional-consumer restore is never retried (high).
deriveAndPersist writes w.persisted = StateConflictFinalized before restoreProvisionalConsumers runs. If RestoreConsumedVTXO fails for any outpoint, the function returns early keeping the edges — but the state is already persisted as ConflictFinalized. Two mechanisms then prevent retry:

  • Future events re-derive StateConflictFinalized, which equals w.persisted, so deriveAndPersist returns at line 628 without re-invoking handleConsumerLifecycle.
  • Reconcile (line 761) only re-arms the live states; ConflictFinalized is excluded, so startup never revisits it.

The orphaned edges + still-forfeited VTXOs are stranded. This compounds with finding #1 (even if the edge were retried, the resident-actor guard would swallow it). Worth a startup scan that restores any batch still carrying batch_provisional_consumers rows, or driving the restore before persisting the terminal state.

3. round/actor.gopendingCommitmentConfs leaks on round failure/reorg (medium).
handleCommitmentFinalized deletes the cache entry (line 2402), but neither onRoundComplete (2182) nor reapFailedRounds (2229) clears it — both only touch rounds and commitmentTxIndex. A round that receives a first confirmation (entry cached at 2308) but then fails, is reaped, or reorgs out before finality leaks its *ConfirmationEvent for the daemon's lifetime. Slow and bounded, but real. Add delete(a.pendingCommitmentConfs, roundFSM.TxID) to reapFailedRounds and delete(a.pendingCommitmentConfs, txid) to onRoundComplete. Fix this →


Additional observations

4. vtxo/manager.go:1281-1317 — the lineage gate re-loads a full descriptor per candidate, defeating the selection projection (medium, efficiency).
selectAndReserveVTXOs deliberately uses ListSelectionCandidatesByStatus (a lightweight 3-field projection) to avoid decoding full descriptors on the per-payment path. gateUnavailableLineage then immediately calls m.cfg.Store.GetVTXO(ctx, c.Outpoint) for every live candidate to recover Ancestry — and each GetVTXO issues its own singleton ancestry query (db/vtxo_store.go:197 passes nil preloaded → the N+1 fallback in rowToDescriptor). For a wallet with many live VTXOs, every coin selection now fans out to one round-trip per candidate before selection even narrows the set. The descriptor cache absorbs the derivation cost but not the ancestry query or tx overhead. Consider gating lazily during largest-first selection (only touch coins actually being considered), or batch-loading lineage state for the candidate set in one query. Correctness is fine — just wanted to flag the regression against the projection's intent.

5. Minor: deriveAndPersist ordering. The pattern of persisting the new state and then firing side effects (handleConsumerLifecycle) means a side-effect failure is silently decoupled from the state that triggered it (root cause of #2). Even outside the ConflictFinalized case, it's worth documenting that consumer-lifecycle side effects are best-effort and rely on an external retry/reconcile path — currently that path doesn't exist for the terminal states.


Things that looked good on close inspection

  • The OOR multi-parent registerLineageBatches goroutine (oor/session_actor_handlers.go:735) correctly uses a detached context.WithTimeout(context.Background(), …), so there's no request-cancellation lifetime bug — nicely done, given the repo's context-lifecycle rules.
  • lineageCommitmentTxids correctly dedups and includes the direct commitment txid even for empty-ancestry (incoming) VTXOs; the multi-parent gate does receive populated Ancestry because the singleton GetVTXO path loads it (per multi: stage boarding actor client  #4).
  • ResolveReorgSafetyDepth guaranteeing a positive result (avoiding disabled Done-synthesis / leaked watches) is a good invariant, and the testnet-100 rationale is sound.
  • deriveState priority ordering and the forEachInputWatch "same outpoint under multiple batches" handling for the conflict case are correct.

Nothing here is a merge blocker for the stack's intent, but findings #1 and #2 are reorg-reversal correctness gaps (funds stuck forfeited) that I'd fix before this gates live in anger.
· branch reorg-canonicality-gates

@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this — pushed fixes addressing the prior review comments.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 1m 19s —— View job


Re-review — fixes for prior review comments

I re-reviewed the three new commits (3197cbf, 6e441bd, 588a5b6) against origin/reorg-observe. All three prior findings are correctly resolved, and one fix is more thorough than the original suggestion. No regressions spotted.


✅ Finding #1 — resident forfeited-VTXO DB reconcile (3197cbf)

handleRestoreForfeitedVTXO (vtxo/manager.go:976-1002) now completes the missed status flip in the resident-actor branch: it calls Store.UpdateVTXOStatus(ctx, req.Outpoint, VTXOStatusLive) and returns Restored: true instead of short-circuiting. This closes the "live actor / DB still Forfeited" gap — a failed status write on a prior attempt is now healed on re-delivery rather than being permanently swallowed. Correct fix, and the comment accurately explains the spawn-before-flip ordering that makes the retry safe.

✅ Finding #2 — interrupted provisional-consumer restore (6e441bd)

Reconcile (batchcanon/manager.go:786-795) now sweeps ListBatchesByState(StateConflictFinalized) and re-drives restoreProvisionalConsumers for each. I verified the two properties this relies on:

  • IdempotentrestoreProvisionalConsumers (manager.go:686-700) returns early when len(consumed) == 0, so re-driving already-restored batches is a genuine no-op.
  • Terminal-state reachabilityListBatchesByState is on the store interface (store.go:34) and backed by both the fake and DB stores, so terminal conflicts that never re-arm watches are now revisited on startup.

This composes correctly with fix #1: the re-driven RestoreConsumedVTXO callback now flows into a handleRestoreForfeitedVTXO that reconciles the DB even when the actor is already resident — so the compounding failure I flagged is gone. The new TestManagerReconcileRestoresInterruptedForfeit seeds exactly the partial-failure state (persisted ConflictFinalized + retained edge) and asserts the sweep completes it. 👍

✅ Finding #3pendingCommitmentConfs leak (588a5b6)

More thorough than my suggestion — the entry is now dropped at all three round-removal sites rather than just two:

  • handleCancelRound (actor.go:2172-2179) — also newly clears commitmentTxIndex, which was previously leaked on cancel too.
  • onRoundComplete (actor.go:2207-2209)
  • reapFailedRounds (actor.go:2258-2264)

Keyed on the commitment txid at each site; the zero-txid case for a round cancelled before its commitment was seen is a harmless no-op delete, as the comment notes. The map is now bounded by the live round set.


Notes on my earlier non-blocking observations

These weren't part of the gemini review comments the push targeted, so I don't expect them addressed here — just recording status:


Nothing new surfaced. The reorg-reversal correctness gaps (funds stuck forfeited) that I called out as "fix before this gates live" are resolved. LGTM on the fixes. Note: I couldn't run go build/tests in this environment (not permitted) — this is an inspection-based review; CI covers compilation and the new tests.
· branch reorg-canonicality-gates

@ellemouton
ellemouton force-pushed the reorg-canonicality-gates branch from 588a5b6 to 4e321b4 Compare July 13, 2026 16:02
@ellemouton
ellemouton force-pushed the reorg-canonicality-gates branch 2 times, most recently from d0fc059 to e2a235a Compare July 14, 2026 22:31
Add the reorg-aware chain-observation substrate the rest of the
reorg-safety stack consumes: a seq-ordered conf/spend watch lifecycle
(Confirmed -> Reorged -> re-Confirmed -> Done) with height-based
finality synthesis for backends that cannot deliver a native Done
(gRPC lndclient and lwwallet).

Finality synthesis is armed off the select loop, tied to the
sub-actor's long-lived context (a per-attempt timeout would tear the
in-process block-epoch forwarder down the instant it armed), retried
with a capped exponential backoff until it succeeds or the watch's
context is cancelled (a single-confirmation tx has no later event to
piggy-back a retry on), and evaluated against the best height captured
at arm time so a tx already buried past FinalityDepth when the watch
arms finalizes immediately instead of hanging for the next block.
Deliver the full TxConfirmed/TxReorged/re-TxConfirmed/TxFinalized/
TxFailed cycle on confirmation watches, with a terminal seal guarding
the 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.
Forward the lnd and lndclient chain-notifier reorg/finality signals
(NegativeConf -> reorg) into the reorg-aware chainsource lifecycle with
buffered forwarding.
Reorg-aware lwwallet/Esplora backend: TipPoller same-height and deeper
reorg detection via PrevBlock continuity, a unified ChainEvent stream,
and BlockDisconnected emission to btcwallet before connecting the
replacement tip.
Forward Neutrino chain-notifier reorg signals into the reorg-aware
chainsource lifecycle.
Consume the reorg-aware substrate at the edges: make the wallet
boarding sweep reorg-aware, enable height-based finality on the darepod
chainsource actor, add the harness GetRawTransaction / SignedV3Tx
helpers, and add the end-to-end reorg systests for chainsource and
txconfirm.
Squashed for the btcd v2 port. Durable batch-canonicality data model:
batchcanon package (State/Record/EffectiveExpiry/ProvisionalConsumer,
behavior-free Store), DB migration + persistence store +
BackfillFromVTXOs, and the expiry-as-terminal audit. No manager, chain
watching, or admission (those are C3+).
Squashed for the btcd v2 port. The batchcanon.Manager actor: one
reorg-aware conf watch per batch + one spend watch per consumed input
via chainsource, derives State by priority, recomputes effective
expiry on reconfirm, and reconciles non-final watches on restart. No
admission (that is C5).
Squashed for the btcd v2 port. batchcanon Availability vocab
(available_final/provisional/unknown, limbo_reorg/conflict,
invalidated) + CombineAvailability + store-driven LineageBlocked, and
the vtxo.Manager coin-selection/forfeit admission gate that drops
candidates whose batch lineage is limbo/invalidated. Permissive for
unseen/unregistered; no-op when the store is nil.
Squashed for the btcd v2 port. The round registers its round-born
batch + consumed inputs with the canonicality manager, and gates
pre-commitment progression on consumed-input canonicality (finality
gate kept as interim safety).
Squashed for the btcd v2 port. OOR registers every batch parent in the
received-VTXO proof lineage with the canonicality manager, and the
VTXO gate combines availability across all ancestry parents
(worst-state AND) for multi-parent OOR VTXOs.
Squashed for the btcd v2 port. Unroll gates fresh admission on the
source VTXO's batch-lineage canonicality (blocks only Invalidated,
fail-permissive).
Squashed for the btcd v2 port. Flag-day activation: darepod builds the
batchcanon store, backfills from VTXOs at best height, registers +
reconciles the manager, and threads the store into vtxo/unroll configs
and fn.Some(ref) into round/oor. Includes the F-series reorg systests
(F2/F3/F4/F6, ReorgExcludingMempool harness helper) + reorg-safety
depth config and the consumed-input pkScript fix.
RestoreForfeitedVTXO spawns the live actor before persisting the
VTXOStatusLive flip, so a failed status write is re-driven on the next
restore attempt. But the retry short-circuited: a resident actor made
the function return early without ever completing the missed status
write, leaving the coin marked Forfeited in the DB. On the next restart
that VTXO is dropped from live recovery -- a permanent loss of funds.

Complete the missed status flip in the resident-actor branch so the DB
is reconciled with the already-live actor.
When a conflict-finalized batch's forfeit restore fails partway through,
restoreProvisionalConsumers deliberately keeps the reverse-dependency
edges for a retry. But conflict_finalized is terminal: its watches are
not re-armed on Reconcile and the spend-done event that first triggered
the restore never fires again, so the retained edges were never
re-driven and the consumed VTXOs stayed forfeited forever.

Sweep conflict-finalized batches on Reconcile and re-drive their
restores. The restore is idempotent (no-ops when no edges remain), so
this safely completes any interrupted restore across a restart.
pendingCommitmentConfs caches a provisional commitment confirmation so
the matching finality event can replay it. Entries were only cleared on
successful finality; a round that was cancelled, reaped after failure,
or completed via onRoundComplete left its cached conf behind, so the map
grew without bound over the daemon's lifetime.

Drop the cached conf at every round-removal site (cancel, reap, and
complete), keyed on the commitment txid.
Add conflicting / conflict_final columns (0/1 INTEGER, default 0)
to batch_consumed_inputs, extend ListBatchConsumedInputs to return
them, and add a targeted RecordBatchInputConflict update.
Regenerated via make sqlc.

These persist the observed conflict status of each consumed batch
input so restart reconciliation can rebuild the per-input conflict
view rather than re-deriving it only from the batch tx
confirmation. The columns default to 0 (no conflict), so the
first-see insert path is unchanged; only the manager's conflict
transitions write them.

Folded into the existing 000014 migration (pre-production: DBs are
nuked and recreated, so no forward ALTER migration is needed).
On restart, reconcileOne seeded every consumed input's watch as
non-conflicting and seeded the batch's confirmation view from the
persisted state. For a persisted conflict_provisional batch this
meant that if the confirmation was re-observed before the
conflicting spend was re-observed, deriveState recomputed
(confirmed, no conflict) -> provisional and persisted it,
transiently admitting a coin whose input is double-spent.
persisted was only a write-dedup guard, not a floor.

Persist each input's conflict flags (RecordInputConflict, written
before the batch state so a crash leaves the flags ahead of, never
behind, the state) and seed the per-input conflict view from them
on reconcile, so a bare re-confirmation can no longer clear a
conflict it did not resolve.

Also harden reconcile watch-arm failure: arm before recording the
watch (mirroring the initial registration path) and return on
failure, leaving m.watches untouched so a later Reconcile retries
the full arm rather than treating the batch as permanently armed.
A closing deriveAndPersist reconciles any state/flag drift toward
the most-restrictive state.
@ellemouton
ellemouton force-pushed the reorg-canonicality-gates branch from e2a235a to d6dec4e Compare July 15, 2026 20:37
@ellemouton
ellemouton force-pushed the reorg-observe branch 4 times, most recently from 292394e to 233abdd Compare July 16, 2026 20:43
@litbot-9000

Copy link
Copy Markdown
Collaborator

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

@ellemouton

Copy link
Copy Markdown
Member Author

Superseded by the corrected, reviewable stack in #964, #980, and #990. The replacement stack preserves the recovery foundation, adds fail-closed durable canonicality, and completes authenticated producer wiring.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants