Skip to content

multi: Add durable batch canonicality authority - #980

Open
ellemouton wants to merge 16 commits into
reorg-recovery-foundationfrom
reorg-batchcanon-core
Open

multi: Add durable batch canonicality authority#980
ellemouton wants to merge 16 commits into
reorg-recovery-foundationfrom
reorg-batchcanon-core

Conversation

@ellemouton

@ellemouton ellemouton commented Jul 17, 2026

Copy link
Copy Markdown
Member

What this PR does

Creates the client's durable authority for which batch anchors a VTXO and
whether its complete lineage is usable right now
. It complements — it does not
replace — the existing VTXO ownership and unilateral-exit proof stores.

Adds the batchcanon subsystem:

  • Durable batch commitment evidence and complete commitment input sets.
  • Dependent VTXOs and logical consumer edges.
  • Canonicality generations and revisions.
  • Confirmation and conflict watches.
  • Restart reconciliation and a readiness barrier.
  • Fail-closed lineage availability queries.
  • Conditional restoration of VTXOs consumed by an invalidated child.

It also gates normal VTXO coin selection: a provisional VTXO cannot be selected
unless batchcanon says its entire lineage is usable.

sequenceDiagram
    participant D as Client daemon
    participant B as batchcanon
    participant C as Chain source
    participant S as Coin selection

    D->>B: Start
    B->>C: Reconcile persisted batches and watches
    B-->>D: Ready
    S->>B: Query lineage availability
    B-->>S: Usable, limbo, invalid, or reconciling
Loading

Before Ready, safety-sensitive queries fail closed. The subsystem tracks two
distinct graphs: the actual TxIn graph (detects commitment conflicts) and
the logical consumer graph (determines descendants and restore eligibility).

Includes real-chain, seeded mechanism tests for reorg/reconfirmation,
conflicting replacement, consumed-VTXO restoration, and multiple commitment
roots.

Review focus

  • Are actual TxIn edges kept distinct from logical consumer edges?
  • Does registration validate the serialized commitment and complete input set?
  • Is state reduction correct when several observations disagree?
  • Does restart reconciliation complete before readiness?
  • Is conditional restoration protected by the business_revision compare-and-swap?
  • Are database migrations and durable messages upgrade-safe?

Out of scope


Full design & diagrams: REORG_SAFETY_PR_REVIEW_GUIDE.md §4.2. Overall
architecture and PR stack: lumos#454.

@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 the batchcanon package, which implements a client-side batch canonicality authority and data model to handle reorg-safety. It includes the Manager actor for tracking batch confirmations and input spends, a database persistence layer for storing canonicality records and provisional consumer edges, and updates to the vtxo package to support conditional restoration. Feedback on the changes highlights a fail-open behavior in LineageBlocked when an error occurs, which should be corrected to return true (blocked) to maintain a fail-closed design. Additionally, calling Reconcile directly on the Manager after starting its actor loop poses a concurrency risk on the un-synchronized m.watches map, which should be addressed by executing reconciliation sequentially within the actor's message loop or before starting it.

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 batchcanon/availability.go
Comment thread batchcanon/manager.go
@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch from dcc4dd6 to 0e68030 Compare July 17, 2026 00:36
@ellemouton
ellemouton force-pushed the reorg-recovery-foundation branch from 57fe7a8 to 74434ea Compare July 17, 2026 22:48
@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch from 0e68030 to 44eae2a Compare July 17, 2026 22:48
@ellemouton
ellemouton marked this pull request as ready for review July 17, 2026 22:50
@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 2m 41s —— View job


Review: multi: Add durable batch canonicality authority

  • Read core batchcanon files (availability, manager, record, state)
  • Review VTXO admission gate wiring
  • Review DB store + GetVTXO ancestry loading
  • Assess the two gemini-flagged issues
  • Post review

This is a large, carefully-structured PR and the design reads cleanly: the fail-closed reducer, the txid-only identity, the two-graph split (ConsumedInputs vs ConsumerEdge), and the generation/readiness restart barrier all line up with the spec and the package docs. Comments are excellent. Below are the findings, most important first.


🔴 1. Reconcile mutates m.watches off the actor goroutine — real data race (agrees with gemini, but it's a production race, not just tests)

Manager.Reconcile (batchcanon/manager.go:1346) runs on the caller's goroutine and mutates the un-synchronized m.watches map (reconcileOnem.watches[record.BatchTxID] = w, manager.go:1421). But Reconcile also calls armWatches, which registers conf/spend watches with chainsource using m.selfRef as the notify target (manager.go:445, manager.go:522). The moment a watch is armed for an already-confirmed batch, chainsource asynchronously delivers a batchConfirmedMsg to the mailbox, and the actor goroutine runs handleBatchConfirmedcurrentWatch → reads/writes m.watches — concurrently with the ongoing Reconcile loop.

This is not test-only. In waved/server.go initBatchCanonicality, the order is RegisterWithSystem (which starts the actor) → SetSelfRefmgr.Reconcile(ctx). So the same race exists on the real startup path. go test -race on TestManagerReconcileReArmsWatches should surface it.

Note the window is real even for a single batch: reconcileOne arms watches before recording m.watches[txid] = w, so a confirmation notification for that batch can land on the actor goroutine and read the map while Reconcile is writing it.

Suggested fix: make reconciliation an actor message processed sequentially inside Receive (e.g. a reconcileRequest that the manager Tells/Asks itself), or arm watches before the mailbox begins draining. The current structure (a public method touching actor-owned state) breaks the "all canonicality mutations serialize through the single mailbox" invariant stated in the Receive doc comment (manager.go:158).

Fix this →


🟡 2. LineageBlocked returns false (not blocked) on error — fail-open value (agrees with gemini, low severity)

batchcanon/availability.go:212

avail, err := LineageAvailability(ctx, store, batchTxids...)
if err != nil {
	return false, avail, err
}

The sole current caller (vtxo/manager.go gateUnavailableLineage) checks err and aborts selection, so there is no live bug today. But returning false for a boolean literally named blocked in a package whose whole thesis is "fail closed" is a latent trap for the next caller. Return true on error to match the package invariant:

if err != nil {
	return true, avail, err
}

Fix this →


🟢 Minor / observations

  • N+1 reload in the gate. gateUnavailableLineage (vtxo/manager.go:1493) re-issues GetVTXO per candidate. I confirmed this is necessary — selection candidates carry no ancestry, and GetVTXO(nil preloaded) triggers the per-row ancestry load (db/vtxo_store.go:207rowToDescriptor singleton path at :803), which the multi-parent gate depends on. Worth a one-line comment saying the reload is specifically to hydrate Ancestry (not obvious, since the caller already holds a *Descriptor); and if selection sets ever get large, a batched ancestry fetch would be the optimization.

  • EvidenceComplete accepts a zero-value input. record.go:107 rejects input.Value < 0 but a value of exactly 0 passes. Not exploitable given the registration cross-check authenticates the serialized tx, but <= 0 would be marginally stricter for a real prevout.

  • Availability rank ordering is non-contiguous (availability.go:57: LineageReconciling = 6, above Invalidated = 5). That's deliberate and correct (reconciling must dominate so an incomplete lineage never loses to a "known" verdict), but a one-line comment on why reconciling outranks invalidated would help the next reader, since it looks like a typo at a glance.

  • Nice touch: persisting Conflicting/ConflictFinal on ConsumedInput and seeding them in watchFromRecord (manager.go:1455) so restart replay can't transiently downgrade a persisted conflict — this directly backs the safety claim and the TestManagerReconcileConflictNotDowngradedByConfReplay test.


Nothing else stands out as a correctness risk in the reducer or the gate. The finding worth blocking on is #1 (the Reconcile race); #2 is a cheap fail-closed hardening.
· branch reorg-batchcanon-core

@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this — both substantive findings are fixed in current head 1e732f18: lineage query errors return blocked=true, and direct startup Reconcile is serialized with all actor Receive work through one mutex. The package passes go test -race ./batchcanon; the original Gemini threads have replies and are resolved. Please re-review the updated head.

@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 2m 24s —— View job


Re-review: head 1e732f18

Both substantive findings from the last pass are resolved. Details below.


✅ Finding #2LineageBlocked now fails closed

batchcanon/availability.go:212-214 returns true (blocked) on any store/query error, and TestLineageAvailabilityFromStore pins it with a failingReader asserting blocked == true and avail == AvailabilityUnknown even when the caller inspects the error. This matches the package's fail-closed thesis. Good.

✅ Finding #1Reconcile/Receive race closed

The fix adds a plain sync.Mutex (m.mu) taken in both Receive (manager.go:169) and Reconcile (manager.go:1358). I traced every m.watches access — lines 287/300 (handleRegisterBatch), 765 (loadLineage), 983 (currentWatch), and the reconcile path 1375/1420/1435. Every one is reached either inside a Receive handler or directly inside Reconcile/reconcileOne, so all map mutations now serialize on m.mu. The specific window I flagged before — reconcileOne arming watches before recording m.watches[txid] = w while a batchConfirmedMsg lands on the actor goroutine — is closed, because that notification's handler must first acquire m.mu, which Reconcile holds for the whole loop.

No self-deadlock: armWatches blocks on ChainSource.Ask(...).Await() (a different actor), never Asks m.selfRef, and confirmation replay is delivered by chainsource via ref.Tell (chainsource/conf_actor.go:648) onto the batchcanon mailbox — that just queues behind the held lock. The mutex is non-reentrant, but no Receive handler calls Reconcile, so re-entry can't happen.

🟢 One residual observation (non-blocking)

Reconcile now holds m.mu across the full reconciliation loop, including the blocking ChainSource.Ask(...).Await() calls in armWatches. This is intentional and correct for the startup contract (watches must be live before startup completes), and it coarsely stalls the actor mailbox for the duration — fine at startup. The one thing worth a sanity check: chainsource delivers replayed confirmations via ref.Tell while Reconcile holds the lock and is itself blocked on a subsequent chainsource Ask. This is only deadlock-free if TellOnlyRef.Tell is non-blocking (or bounded-with-error) rather than blocking-on-full — the if err := ref.Tell(...) pattern in conf_actor.go suggests it is, but confirming Tell never blocks the chainsource goroutine indefinitely would fully close the reasoning. Given go test -race ./batchcanon passes (per your note), this is theoretical.

Prior minor notes (unchanged, still non-blocking)

The earlier observations still stand as optional polish, not regressions: the N+1 ancestry reload in gateUnavailableLineage (necessary, worth a one-line comment), EvidenceComplete accepting a zero-value input (record.go:107, <= 0 would be marginally stricter), and a one-line "why reconciling outranks invalidated" comment at availability.go:57.


Verdict: Both blocking/medium findings are correctly fixed and test-covered. Nothing new blocks. Note: I could not run go test -race myself in this environment (command not permitted), so I verified the fix by tracing every m.watches access statically and confirming the lock discipline; I'm relying on your reported go test -race ./batchcanon pass for the runtime confirmation.
· branch reorg-batchcanon-core

@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch from 1e732f1 to 8dbd209 Compare July 17, 2026 23:27
@ellemouton
ellemouton force-pushed the reorg-recovery-foundation branch from ce34567 to 747f805 Compare July 17, 2026 23:27
@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch from 8dbd209 to c566338 Compare July 17, 2026 23:38
@ellemouton
ellemouton force-pushed the reorg-recovery-foundation branch from 747f805 to 428fd45 Compare July 18, 2026 00:00
@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch 2 times, most recently from 899c934 to 9d3b6b0 Compare July 18, 2026 00:20
@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown

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


Re-review: head 9d3b6b0

Both blocking findings from the earlier passes are still in place: LineageBlocked fails closed on store error (availability.go:212-214), and startup Reconcile serializes with Receive via m.mu (manager.go:169, 1358). The reducer, the txid-only identity, the two-graph split, the generation/readiness barrier, and the restore CAS all continue to read cleanly. One new finding, then observations.


🟡 1. Down migration 000015 is incomplete — not a full reverse of the up

db/sqlc/migrations/000015_batch_canonicality.down.sql drops only four of the five new tables and neither of the two new vtxos columns. The up migration adds:

  • 5 tables: batch_canonicality, batch_consumed_inputs, batch_dependent_vtxos, batch_provisional_consumers, batch_consumer_creator_lineage
  • 2 columns on vtxos: business_revision, forfeit_consumer_txid

The down drops the first four tables but omits batch_consumer_creator_lineage and both column drops. This diverges from the repo convention — 000013_ancestry_commitment_height.down.sql explicitly does ALTER TABLE ... DROP COLUMN to fully reverse its up. Consequences of a down-then-up cycle (the way golang-migrate down is used for local resets / test tooling):

  • batch_consumer_creator_lineage survives the down with stale rows; the up's CREATE TABLE IF NOT EXISTS then silently skips it, leaving orphaned data.
  • Re-running the up fails on ALTER TABLE vtxos ADD COLUMN business_revision with a duplicate-column error, since the down never removed it.

Suggested down (drop the FK child before its parent, then the columns):

DROP TABLE IF EXISTS batch_consumer_creator_lineage;
DROP TABLE IF EXISTS batch_provisional_consumers;
DROP TABLE IF EXISTS batch_dependent_vtxos;
DROP TABLE IF EXISTS batch_consumed_inputs;
DROP TABLE IF EXISTS batch_canonicality;

ALTER TABLE vtxos DROP COLUMN forfeit_consumer_txid;
ALTER TABLE vtxos DROP COLUMN business_revision;

Severity note: this is latent, not a live production bug — TestMigrationDowngrade (db/migrations_test.go:82) confirms the production migrator rejects downgrades outright (ErrMigrationDowngrade), so the down path never runs in a deployed daemon. But the down file is checked-in code expected to be correct and consistent with the up (and with 000013), so it's worth fixing.

Fix this →


🟢 Observations (non-blocking)

  • MarkForfeited now hard-requires a non-zero consumer batch txid (db/vtxo_store.go:626). I traced the only production emitter: ForfeitingStateVTXOStatusUpdate{ConsumerBatchTxID: evt.CommitmentTxID} (vtxo/transitions.go:862), fed by round's ForfeitConfirmedToVTXO{CommitmentTxID: evt.TxID} (round/transitions.go:4443), which is a confirmed-tx id and so always set. So the new precondition is safe today. Worth being aware that any future emitter of a Forfeited status update must populate ConsumerBatchTxID or the persist will hard-error and fail the transition.

  • Reconcile holds m.mu across the whole arm-watches loop, including each blocking ChainSource.Ask(...).Await(). Confirmed non-deadlocking as long as chainsource delivers replayed confirmations via a non-blocking TellOnlyRef.Tell onto the batchcanon mailbox (the queued message just waits behind the held lock). This was flagged in the prior pass and remains theoretical given the reported go test -race ./batchcanon pass; re-confirming Tell is bounded-non-blocking under a full mailbox would fully close it.

Prior minor notes (still applicable, optional polish)

  • N+1 ancestry reload in gateUnavailableLineage (vtxo/manager.go:1497) — necessary to hydrate Ancestry; a one-line comment would help, and a batched fetch is the optimization if selection sets grow.
  • EvidenceComplete accepts a zero-value input (record.go:107, input.Value < 0<= 0 would be marginally stricter); safe given the registration cross-check.
  • A one-line "why reconciling outranks invalidated" comment at availability.go:68 would explain the deliberately non-contiguous rank.

Verdict: No new blocking correctness risk. Both previously-flagged blocking issues remain fixed. The one new item is the incomplete 000015 down migration (finding #1) — latent (prod downgrades are blocked) but worth correcting for consistency with the up and with the 000013 convention.

Note: I verified statically (tracing map access, migration up-vs-down, the forfeit emitter chain); I could not run go test -race in this environment.
· branch reorg-batchcanon-core

@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch from 9d3b6b0 to 095039e Compare July 18, 2026 00:38
@ellemouton

Copy link
Copy Markdown
Member Author

Fixed at current head: 000015_batch_canonicality.down.sql now drops batch_consumer_creator_lineage before its parent tables and removes both added vtxos columns. SQL generation, SQLite/PostgreSQL migration tests, and changed-code lint pass. @claude review this

@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 53s —— View job


Re-review: 000015 down-migration fix

  • Verify down migration now fully reverses the up (5 tables + 2 columns)
  • Confirm drop ordering respects FK dependencies
  • Re-confirm prior fixes still hold
  • Post verdict

✅ Finding #1 (incomplete down migration) — fixed

db/sqlc/migrations/000015_batch_canonicality.down.sql is now a complete reverse of the up:

DROP TABLE IF EXISTS batch_consumer_creator_lineage;
DROP TABLE IF EXISTS batch_provisional_consumers;
DROP TABLE IF EXISTS batch_dependent_vtxos;
DROP TABLE IF EXISTS batch_consumed_inputs;
DROP TABLE IF EXISTS batch_canonicality;

ALTER TABLE vtxos DROP COLUMN forfeit_consumer_txid;
ALTER TABLE vtxos DROP COLUMN business_revision;

Traced it against the up migration and confirmed:

  • All 5 tables dropped — the previously-omitted batch_consumer_creator_lineage is now present.
  • FK-safe drop order. batch_consumer_creator_lineage (child, FK → batch_provisional_consumers, up lines 215–219) is dropped before its parent, and all three batch_canonicality children (batch_provisional_consumers, batch_dependent_vtxos, batch_consumed_inputs) drop before batch_canonicality itself. Correct even with FK enforcement on.
  • Both added vtxos columns droppedbusiness_revision and forfeit_consumer_txid, in reverse of their add order, matching the 000013 convention.

A down-then-up cycle is now clean: no orphaned batch_consumer_creator_lineage rows surviving the down, and no duplicate-column error on ALTER TABLE vtxos ADD COLUMN business_revision when re-running the up.

db/migrations.go correctly bumps LatestMigrationVersion 14 → 15 to match the new file.

✅ Prior blocking findings still hold

  • LineageBlocked fails closed (blocked=true) on any store/query error (availability.go:212-214).
  • Startup Reconcile serializes with actor Receive via m.mu (manager.go:169, 1358).

Verdict: The last outstanding finding is resolved correctly, and no prior fix regressed. No remaining blocking issues. The earlier optional-polish notes (N+1 ancestry reload comment in gateUnavailableLineage, EvidenceComplete <= 0 strictness, the "why reconciling outranks invalidated" comment) still stand as nits, not blockers.

Note: verified statically (down-vs-up table/column diff, FK drop ordering, version bump); per your report SQL generation and the SQLite/PostgreSQL migration tests pass, which I could not run in this environment.
· branch reorg-batchcanon-core

@ellemouton
ellemouton force-pushed the reorg-recovery-foundation branch from 428fd45 to b6c2fd0 Compare July 20, 2026 16:24
@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch from 095039e to 7f636e7 Compare July 20, 2026 16:24
@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this — final head is now rebased onto the refreshed foundation/current main; the batch canonicality migration is renumbered to 16 after main's migration 15.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

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


Re-review: rebased head — migration renumbered 1516

  • Verify migration renumber to 000016 (up + down consistent, version bump)
  • Confirm no collision with main's migration 15
  • Re-confirm prior blocking fixes still hold
  • Post verdict

The rebase is clean and the renumber is correct. Details below.


✅ Migration renumber 000016 is consistent

  • No collision. Main's new migration 15 is 000015_ledger_round_uuid (materializes the TEXT UUID form of round_id) — entirely unrelated to batch canonicality. Batch canonicality correctly moved to 000016_batch_canonicality.{up,down}.sql, so the sequence …013, 014, 015 (main), 016 (this PR) has no gap or clash.
  • Version bump matches. db/migrations.go:13 sets LatestMigrationVersion = 16 (and latestVersion: LatestMigrationVersion at :50), consistent with the highest file number.
  • Up still complete. 000016…up.sql adds the 2 vtxos columns (business_revision, forfeit_consumer_txid) and all 5 tables (batch_canonicality, batch_consumed_inputs, batch_dependent_vtxos, batch_provisional_consumers, batch_consumer_creator_lineage), with the FK children REFERENCES … ON DELETE CASCADE.
  • Down still a full, FK-safe reverse — carried over intact from the prior fix:
    DROP TABLE IF EXISTS batch_consumer_creator_lineage;   -- child, dropped first
    DROP TABLE IF EXISTS batch_provisional_consumers;
    DROP TABLE IF EXISTS batch_dependent_vtxos;
    DROP TABLE IF EXISTS batch_consumed_inputs;
    DROP TABLE IF EXISTS batch_canonicality;               -- parent, last
    ALTER TABLE vtxos DROP COLUMN forfeit_consumer_txid;
    ALTER TABLE vtxos DROP COLUMN business_revision;
    batch_consumer_creator_lineage (FK → batch_provisional_consumers) drops before its parent, and all three batch_canonicality children drop before it. A down-then-up cycle stays clean.

✅ Prior blocking findings still hold after rebase

  • Fail-closedLineageBlocked returns true, avail, err on any store/query error (availability.go:213-215).
  • Reconcile/Receive race closedm.mu is taken in both Receive (manager.go:171) and startup Reconcile (manager.go:1358), serializing all m.watches access.

Prior optional-polish notes (still non-blocking)

Unchanged and still just nits, not regressions: the N+1 ancestry reload in gateUnavailableLineage (necessary to hydrate Ancestry; a one-line comment would help), EvidenceComplete accepting a zero-value input (record.go:107, <= 0 marginally stricter), and a one-line "why reconciling outranks invalidated" comment at availability.go.


Verdict: Rebase and migration renumber (1516) are correct and internally consistent — no collision with main's 000015, version bumped to 16, up/down fully symmetric and FK-safe. Both previously-flagged blocking fixes survive the rebase. No new blocking issues.

Note: verified statically (migration file diff, version bump, FK drop ordering, fail-closed + mutex discipline); go build/go test/migration tests are not runnable in this environment, so I'm relying on your reported local make build + SQLite/Postgres migration test passes for the runtime confirmation.
· branch reorg-batchcanon-core

@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch from 7f636e7 to 8943a38 Compare July 20, 2026 18:46
@ellemouton
ellemouton force-pushed the reorg-recovery-foundation branch from df853f3 to a2f9319 Compare August 5, 2026 19:19
@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch from 054e7bb to 851acf0 Compare August 5, 2026 19:28
@ellemouton
ellemouton force-pushed the reorg-recovery-foundation branch from a2f9319 to cc855f4 Compare August 5, 2026 21:39
@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch 2 times, most recently from e9a0841 to 9731c80 Compare August 5, 2026 21:43
@ellemouton
ellemouton force-pushed the reorg-recovery-foundation branch from cc855f4 to d16dc15 Compare August 5, 2026 22:15
@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch from 9731c80 to e3bcacb Compare August 5, 2026 22:15
@ellemouton
ellemouton force-pushed the reorg-recovery-foundation branch from d16dc15 to 5c278e2 Compare August 5, 2026 22:59
@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch 2 times, most recently from f31cccf to dbadb1b Compare August 5, 2026 23:06
@litbot-9000

Copy link
Copy Markdown
Collaborator

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

Add the durable schema for the batch canonicality authority: the batch
record, its consumed inputs, dependent VTXOs, consumer edges,
observation facts, and the registration/readiness columns, plus the
generated sqlc queries. A business_revision column on the VTXO table
records the exact lifecycle revision a forfeiture installs, so the
later conditional restore can compare-and-swap against it.

No behaviour yet; this is the storage substrate the authority and the
VTXO admission gate build on.
Add the client-side batch canonicality authority: the reorg-aware data
model (Record keyed by txid, ConsumedInput, and the logical
ConsumerEdge graph), the dependency-light reducer that derives
fail-closed lineage availability, and the Manager that arms
reorg-aware watches and runs the versioned Reconcile(g)/Ready(g)
restart barrier.

Registration authenticates the serialized commitment transaction (its
hash and full TxIn set) before a record can reach Ready, so an omitted
or unauthenticated input keeps the lineage unavailable. Missing,
incomplete, unarmed, or reconciling lineage is never usable. The
package depends only on btcd and lnd/fn so the server can reuse the
same canonicality semantics.
@ellemouton
ellemouton force-pushed the reorg-recovery-foundation branch from 5c278e2 to e68e9f6 Compare August 10, 2026 18:02
Implement the durable canonicality Store over the schema and wire the
VTXO manager's admission path onto the authority: coin selection and
forfeit admission load a candidate's complete inherited lineage and
refuse anything whose worst-parent availability is not usable.

Track each VTXO's business revision and forfeit-consumer batch so a
terminally invalidated consumer can restore a consumed VTXO only
through a conditional compare-and-swap on its exact expected
forfeiture marker.

Round and OOR lineage registration before exposure, the remaining
operation gates, and the reorg/restart scenario tests land in the
following PRs.
Add an optional BatchCanonicality store to the VTXO manager and drop
coin-selection candidates whose batch lineage is not a ready, confirmed
member of the canonical chain. This includes reorged-out, invalidated,
missing, reconciling, or unregistered lineage. The nil-store default is
behaviour-neutral until round and OOR producers register their batches.
Wire the batch-canonicality authority into daemon startup so the VTXO
coin-selection reorg-safety gate (lumos#454) can run against the live
chain. On start (after the chain source is registered) waved now builds
the durable canonicality store, backfills fail-closed placeholders from
existing VTXOs anchored to the current tip, registers + reconciles the
BatchCanonicalityManager actor, and stashes the store and manager ref on
the Server.

The manager is always built, reconciled, and left observing so a reorg
that lands while the daemon is down is detected on the next start. The
VTXO admission gate itself is threaded into the VTXO ManagerConfig only
when the new BatchCanonicalityGate config flag is set. The gate is
fail-closed: until the round and OOR producers register their batches
(a follow-up in the reorg-safety stack), every VTXO lineage would be
unregistered and therefore excluded, stranding all liquidity. The flag
defaults false so the daemon stays behaviour-neutral until producer
registration lands, and flips to true (the intended steady state) once
producers register batches.
Add the systest primitives the batch-canonicality reorg scenarios need:

  - ReorgExcludingMempool mines the replacement branch with EMPTY blocks
    (generateblock) so a disconnected transaction is NOT auto-reconfirmed
    on the new branch. This makes the post-reorg "transaction off-chain"
    window deterministic instead of the tx silently reconfirming from the
    mempool on the first replacement block (as it does under Reorg's
    generatetoaddress). Reorg and ReorgExcludingMempool now share a
    reorgWith driver.

  - FirstSpendableOutpoint returns a confirmed wallet outpoint plus its
    value and pkScript without spending it, so a test can register it as
    a batch's consumed input (the pkScript is required to arm the spend
    watch).

  - BuildSignedSpend builds, signs, and broadcasts a 1-in/1-out tx
    spending that outpoint, returning the fully signed wire.MsgTx and its
    txid. The corrected batch-canonicality registration authenticates the
    serialized commitment tx and its exact input set, so a seeded batch
    must be a real transaction whose single input matches the registered
    ConsumedInput; an opaque sendtoaddress faucet tx cannot satisfy that.
    SpendOutpoint is a thin wrapper for controlled double-spends.

bitcoindFirstSpendableUTXO now also returns the output scriptPubKey.
Add TestBatchCanonicalityGateBlocksReorgedVTXO (F2): an end-to-end,
real-reorg proof that the batch-canonicality coin-selection gate makes a
VTXO usable at ONE confirmation, unavailable when its batch reorgs off
the canonical chain, and usable again once the batch reconfirms.

The test wires a real chainsource actor over the harness LND, a real
batchcanon.Manager arming reorg-aware watches, and a real vtxo.Manager
whose BatchCanonicality store is the same durable store the manager
writes -- mirroring waved's activation. The batch (commitment) tx is a
real wire.MsgTx built by the harness that spends one wallet outpoint, so
the authenticated registration (serialized tx hash + every TxIn) is
satisfied. A single seeded live VTXO anchored on that batch is the sole
coin-selection candidate, so the contrast across three beats isolates
the gate:

  1. Provisional (1 conf): SelectAndReserveSpend succeeds, then release.
  2. ReorgExcludingMempool strands the batch (stable ReorgedOut):
     SelectAndReserveSpend fails.
  3. Reconfirm to Provisional: SelectAndReserveSpend succeeds again.
The batch-canonicality conflict systests must double-spend a REAL input
of an already-confirmed batch transaction. Only one transaction spending
a given outpoint can sit on the canonical chain at a time, so creating
the conflict requires reorging the batch tx out and confirming a
competing spend of the same input in its place.

Add BuildSignedSpendNoBroadcast so the competing double-spend can be
signed while the input is still unspent (avoiding a mempool conflict at
build time) and mined later, and ReorgReplacingTxs (plus its
generateBlockWithTxs primitive) which invalidates the tip and mines a
strictly-longer replacement branch whose first block confirms the given
transactions against the UTXO set rather than pulling the mempool.
Refactor BuildSignedSpend onto a shared buildSignedSpend so the
broadcast and no-broadcast variants cannot drift.
The coin-selection reorg-safety gate previously combined availability
over a VTXO's direct commitment txid only. A multi-input (OOR-born) VTXO
descends from more than one batch, and any single reorged-out or
conflict-invalidated parent makes the leaf unspendable, so the gate must
reduce over the whole lineage and take the worst state.

Add lineageCommitmentTxids, which collects a candidate's direct
commitment plus every distinct cross-commitment ancestor recorded in its
ancestry, and feed the full set into LineageBlocked (worst-of-N via
CombineAvailability). Single-commitment VTXOs yield exactly the previous
one-element input, so behaviour is unchanged for them.
Extend the seeded real-chain gate systests to the input-conflict path. A
VTXO whose batch confirmed at one confirmation is admitted into coin
selection; double-spending one of the batch's registered consumed inputs
with a competing transaction drives the batch ConflictProvisional
(limbo_conflict) and excludes the VTXO; reorging the conflicting spend
away lets the batch reconfirm and re-admits the VTXO; and
re-establishing the conflict and maturing it past the reorg-safety depth
drives the batch ConflictFinalized (invalidated), excluding the VTXO
terminally.

This trips the per-input spend watch rather than the batch conf watch
that the F2 reorg test exercises. Because the corrected registration API
is authenticated (the manager cross-checks the serialized batch tx, its
output, and every TxIn), the conflict is a double-spend of a real batch
input created via the tx-replacing reorg helpers.
Extend the seeded real-chain gate systests to the reverse-dependency
restore. A VTXO is forfeited into a consumer batch (MarkForfeited stamps
the forfeit-consumer marker and business revision the restore CAS keys
on), and the consumer batch is registered with an authenticated
ConsumerEdge binding that revision plus the complete creator lineage.

Maturing a conflicting double-spend of the consumer batch's input past
the reorg-safety depth drives it ConflictFinalized, which fires the
store's conditional-restore compare-and-swap: the VTXO is atomically
restored to Live and re-admitted into coin selection. The test also
proves the no-false-restore guard: a consumer batch that only reorgs out
and reconfirms (never final) leaves the forfeit standing.
Extend the seeded real-chain gate systests to a multi-input (OOR-born)
VTXO whose lineage spans two batches: a direct commitment plus a
distinct cross-commitment ancestor. Reorging only the ancestor out
excludes the VTXO even while the direct commitment stays confirmed
(worst-parent), and reconfirming the ancestor re-admits it.

This exercises the full-lineage gate: the two parents are confirmed in
distinct blocks so reorging only the tip block cleanly targets the
ancestor, making the contrast unambiguous.
Fail closed when lineage storage errors so admission callers never see a
false unblock signal alongside the error. Protect startup reconciliation
with the same mutex as actor delivery because the actor is live before
the daemon invokes Reconcile.
Capture the pre-confirmation height supplied by batch producers and
reuse it for initial and restart watch registration. This prevents
delayed light-client watch installation from starting above a
confirmation that already raced onto the chain.
Allow repeated registrations to report a different observation height. A
reconfirmation can legitimately move that value, while retaining the
original lower scan point remains safe and avoids quarantining otherwise
identical evidence.

Clamp the hint to a floor of 1 before arming any watch. A chain notifier
rejects a height hint of 0 ("a height hint greater than 0 must be
provided"), and a persisted hint can legitimately resolve to 0 on a node
whose round FSM was created at genesis height on a fresh chain. Without
the floor the confirmation and spend watches never arm, so a fail-closed
batch never becomes usable and round confirmation stalls.
main added 000016_round_sweep_delay while this stack was open, which
collides with this stack's 000016_batch_canonicality. Renumber the
latter to 000017 so migration versions stay unique. The consolidated
schema is unchanged: the two migrations touch independent tables.
@ellemouton
ellemouton force-pushed the reorg-batchcanon-core branch from dbadb1b to 6b3483a Compare August 10, 2026 18:05
@litbot-9000

Copy link
Copy Markdown
Collaborator

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants