Skip to content

tapgarden: large scale hardening#2153

Closed
jtobin wants to merge 56 commits into
lightninglabs:mainfrom
jtobin:tapgarden-hardening
Closed

tapgarden: large scale hardening#2153
jtobin wants to merge 56 commits into
lightninglabs:mainfrom
jtobin:tapgarden-hardening

Conversation

@jtobin

@jtobin jtobin commented Jun 4, 2026

Copy link
Copy Markdown
Member

Ok, hang on, hear me out..

I can hear you now. 47 commits? +6500/-4000?! April 1st was like two months ago, what gives?

This is the result of a general tapgarden refactor I embarked on after making #2141. That was sort of the straw that broke the camel's back; by the time I'd made that PR, I had literally lost count of the number of problems I'd encountered and fixed in tapgarden. So, I decided to investigate more deeply, by way of a holistic analysis, why the package has historically been so buggy.

The result is probably best described by summarizing what problems, previously possible, are now made structurally impossible, and by what broad change each was accomplished. So, a summary, ctsy Opus:

  • Persistence atomicity. Previously it was possible for an in-memory batch state to advance while the corresponding database write failed, leaving memory and disk disagreeing about what state the batch was in; now, because every state mutation flows through a store method that touches memory only after the database commit succeeds, the two views cannot drift.

  • Funding overload. Previously it was possible for a fund-batch call to silently create a new batch when the caller meant to attach funding to an existing one (the bug behind [bug]: Mint batch can get stuck in FROZEN state after funding failure #2136); now, because "create" and "attach" are separate functions with non-overlapping signatures, the wrong intent will not type-check.

  • Singleton invariant. Previously it was possible for two unbroadcast minting batches to coexist on disk and block all subsequent minting; now, because the schema itself forbids more than one row in the pre-broadcast subset, that state cannot exist.

  • Determinism. Previously it was possible for the planter to pick a different "anchor seedling" on different runs of the same input because the choice depended on Go map iteration order; now, because the lookup scans deterministically and refuses to return when the singleton invariant fails, the choice is fixed.

  • Purity in output-key derivation. Previously it was possible for MintingOutputKey to return a cached value that didn't reflect the sibling argument the caller actually passed; now, because the function holds no state, its output is determined entirely by its inputs.

  • Restart-idempotence of side effects. Previously it was possible for a crash between persisting a batch as broadcast and importing its taproot output to leave the on-chain output unknown to the wallet forever; now, because import precedes the state-transition write, on-disk state can never outpace the side effect it commits.

  • Event deduplication. Previously it was possible for a restart-triggered re-fire of a mint event to log the same logical event twice and double-count the supply-commit update stream; now, because each event is keyed by a content hash with a uniqueness constraint, the second insert is silently absorbed by the schema.

  • No orphaned state-machine transitions. Previously it was possible for a deduplicated event to leave an empty supply-commit transition wedged in UpdatesPending; now, because the insert transaction rolls back whenever it detects a no-op dedupe, no empty transition can ever land.

  • Snapshot isolation. Previously it was possible for a subscriber holding a "snapshot" of a batch to share substructure with the live caretaker and observe mid-flight mutations through it; now, because copies are genuinely deep, snapshots are real isolation barriers.

  • Request/response binding. Previously it was possible for a cancel response to land on a different cancel request than the one that produced it, because the matching depended on a serialization discipline rather than the protocol itself; now, because each request carries its own reply channel, the binding is intrinsic.

  • Shutdown safety. Previously it was possible for a caretaker reaching completion in the same instant the planter was stopping to deadlock the entire shutdown path; now, because the completion send is abandonable on Quit, no notification can outlive its receiver.

Of the net ~2500 LoC added, roughly 900 lines are in added tests (mostly state machine property tests, IIRC), 800 are around reorganization (stuff that moves various 'complecting' machinery into other/new packages), and the rest is mostly in interface scaffolding + singleton-invariant/dedup/repair-flag machinery (the repair flag being a safety mechanism for old databases that disobeyed the implicit singleton invariant to upgrade).

So yes, It's huge, but: it passes our tests, plus new ones, and IMO enough LLM-enhanced eyes on it should be able to vet it appropriately.

@jtobin jtobin added this to the v0.9 milestone Jun 4, 2026
@jtobin jtobin self-assigned this Jun 4, 2026
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a large-scale hardening of the tapgarden minting subsystem. The changes focus on ensuring structural correctness and restart-idempotence for minting batches. By enforcing strict singleton invariants on pending batches, ensuring atomic persistence, and implementing deterministic state transitions, the system is now significantly more resilient to crashes and re-orgs. The refactor also improves code modularity by extracting node-level service interfaces and dedicated subsystems into their own packages.

Highlights

  • Persistence Atomicity: State mutations now flow through a store method that ensures memory is only updated after a successful database commit, preventing drift between memory and disk.
  • Singleton Invariant: A new partial unique index on the minting batches table enforces that at most one batch exists in a pre-broadcast state (Pending or Frozen), preventing deadlock and state corruption.
  • Restart Idempotence: The minting caretaker's restart path is now idempotent; crashes during the sign-and-finalize or broadcast phases are handled by re-running the logic, with duplicate events absorbed by a new content-hash index.
  • Package Reorganization: Subsystems like 'custodian' and 'reorg' have been moved to dedicated packages (tapcustody, tapreorg), and shared node-side interfaces have been consolidated into a new tapnode package.
  • One-Shot Repair Tool: Added a new repair flag (--repair.cancel-duplicate-batches) to allow operators to recover legacy databases that violate the new singleton invariant before applying migrations.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@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 refactors the tapd codebase by modularizing substrate dependencies into the tapnode package and separating concerns into tapreorg, tapcustody, and mintpublish. It also introduces database constraints to enforce a singleton pre-broadcast batch invariant, adds a repair tool to resolve legacy database violations, and implements deduplication for supply update events. The review feedback highlights a critical migration failure risk where the programmatic backfill in migration 62 could fail on legacy databases containing duplicate events. Additionally, feedback points out a shallow copy issue in GroupKey.Copy(), potential nil pointer dereferences in UpdateBatchState and Cultivator.Cancel, and a potential infinite loop in PublishMintBatch if batchSize is non-positive.

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 tapdb/programmatic_migrations.go
Comment thread asset/group_key.go
Comment thread tapdb/asset_minting.go
Comment thread tapgarden/cultivator.go
Comment thread universe/mintpublish/publisher.go Outdated
@jtobin jtobin force-pushed the tapgarden-hardening branch from 7f01e15 to f21a6d7 Compare June 4, 2026 16:55
@lightninglabs-deploy

Copy link
Copy Markdown

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

@jtobin jtobin force-pushed the tapgarden-hardening branch from f21a6d7 to caf0b4f Compare June 29, 2026 13:28
@jtobin jtobin modified the milestones: v0.9, v0.8.2 Jun 29, 2026
@jtobin jtobin force-pushed the tapgarden-hardening branch 2 times, most recently from 080e146 to f30771f Compare July 1, 2026 19:42
jtobin added 16 commits July 1, 2026 18:39
MintingBatch.batchState (in-memory) and the asset_minting_batches.state
DB column were written by separate code paths and could diverge on
failure. Several store methods that transitioned state as a side-effect
(AddSproutsToBatch, CommitSignedGenesisTx, MarkBatchConfirmed) updated
the DB while the in-memory mirror was only patched up later by
advanceStateUntil's blanket post-step write -- or, in places, *before*
the DB write was issued.

Funnel every state mutation through MintingStore methods that take a
*MintingBatch and advance the in-memory field only after the DB write
succeeds. The public UpdateState mutator is removed (renamed to
unexported setState); an exported SetStateOnDBSuccess remains as the
bridge for store implementations. Two redundant in-memory writes in
BatchCaretaker that either re-mirrored state already updated by a store
call or got ahead of the DB are dropped.
fundBatch did one of two things depending on whether its workingBatch
arg was nil: create a fresh batch and persist via CommitMintingBatch,
or attach funding to an existing on-disk batch via CommitBatchFunding.
This overload was the shape of bug that caused lightninglabs#2136 -- the wrong
in-memory reference silently flipped which path ran, and downstream
code that thought it was modifying batch A was in fact creating a
fresh batch B.

Split into three pieces with non-overlapping responsibilities.
createFundedBatch derives, funds, and persists a new batch and never
mutates planter state. applyFundingToBatch funds an existing on-disk
batch atomically and rejects nil up front. fundPendingBatch is a thin
nil-dispatch wrapper used by callers that genuinely have the
create-or-update ambiguity. Start()'s recovery path calls
applyFundingToBatch directly. Shared funding prep (sibling persistence
and the computeFunding closure) is factored into prepareFunding.
prepAssetSeedling assigned or mutated c.pendingBatch before the DB
write that justified the mutation. A failed commit left the in-memory
state ahead of disk: either pointing at a batch that did not exist, or
holding a seedling that the DB had never seen. The next call to
QueueNewSeedling would then either trip a uniqueness check or wedge
the failed seedling in memory indefinitely.

Reorder both branches to persist first and mirror into memory only on
success. To make the existing-batch branch separable, MintingBatch
grows validateSeedling (pure) and commitSeedling (mutation);
AddSeedling remains as the combined wrapper for callers that have no
persistence boundary between the two. The fresh-batch branch builds
entirely on a local newBatch and only assigns c.pendingBatch after
CommitMintingBatch returns.
fetchDelegationKey and fetchPreCommitGroupKey each iterated
pendingBatch.Seedlings and broke after the first entry, picking either
the anchor directly or one that referenced it. Go's map iteration is
non-deterministic, so correctness depended on an invariant ( <= 1
anchor per universe-commitment batch) enforced by validateUniCommitment
elsewhere; break that invariant by one line and the loops silently
picked at random.

Replace both call sites with MintingBatch.uniqueAnchorSeedling, a
deterministic scan that returns the single seedling with GroupAnchor
== nil and errors loudly on zero or more than one match. The invariant
is now asserted at the site that depends on it rather than presumed.
MintingOutputKey memoized its result on first call; subsequent calls
silently ignored the sibling argument. The caretaker's
BatchStateCommitted branch was the only caller that exploited this,
passing nil and relying on the Frozen branch having cached the real
sibling. Any path that reached Committed without going through Frozen
first -- a future RBF retry, a partial restart, a refactor -- would
have silently computed and cached the wrong output key.

Drop the cache. MintingBatch loses its memoized mintingPubKey and
taprootAssetScriptRoot fields; MintingOutputKey recomputes from
(BatchKey, RootAssetCommitment, sibling) on every call. The caretaker's
Committed branch now loads the sibling preimage before calling
MintingOutputKey and passes it explicitly; the same preimage is already
used a few lines below for siblingBytes, so this is a reorder rather
than a new I/O cost. The work is a single tapscript hash plus an ECC
tweak -- not expensive enough to justify a memoizing getter that
introduces correctness footguns.
The planter has long treated a single "current" pre-broadcast batch
as a singleton in code, but the schema admitted plurality. Older
versions of the planter could in some failure scenarios desync the
in-memory slot from disk and leave two pre-broadcast rows behind --
making the in-code invariant unenforceable on a recovering daemon.

Add migration 000060, a partial unique index on asset_minting_batches
that forbids more than one row in BatchStatePending or BatchStateFrozen
at any time. The "(1) WHERE batch_state IN (0, 1)" idiom indexes a
constant expression filtered by the active-set predicate; both
modernc SQLite >= 3.9 and Postgres accept it. Legacy databases that
already hold duplicates fail on startup with the migration error; the
operator recovery path arrives in a follow-up commit. Supply-commit
test fixtures that don't exercise the planter state machine advance
each batch past Frozen immediately after insert so they no longer
trip the constraint. LatestMigrationVersion bumps from 59 to 60.
The DB-level singleton invariant from migration 000060 still leaves
the planter with two operational gaps: at startup, against a legacy
DB that survived migration but otherwise has duplicates, and on
caretaker failure, where the gardener used to leave the orphaned
(typically Frozen) batch behind. Two cancel-related error messages
also predate the invariant and were ambiguous.

Add a defensive pre-Start check that refuses to come up if more than
one non-final batch is Pending or Frozen, naming the offending batch
keys and pointing the operator at --repair.cancel-duplicate-batches.
The gardener's BroadcastErrChan handler now cancels the failed batch
in the same step, mirroring caretaker.Cancel()'s state rule
(Pending/Frozen -> SeedlingCancelled, Committed -> SproutCancelled).
The cancel-ambiguity error wording is rewritten to reflect the actual
source (the cancel API has no batch-key parameter), and an obsolete
TODO becomes an invariant statement referencing migration 000060.
Tests follow: TestCheckSingletonInvariant covers the relevant shapes;
finalize_with_tapscript_tree expects SeedlingCancelled on caretaker
error; fund_seal_on_restart drops the now-unrepresentable two-pending
scenario.
A legacy database populated by an older version of tapd may hold
more than one pre-broadcast batch, in which case migration 000060
fails on startup with a unique-constraint violation and blocks the
daemon indefinitely with no documented recovery path.

Add a one-shot operator flag that opens the database with migrations
skipped, finds all batches in BatchStatePending or BatchStateFrozen,
preserves the most recent (by CreationTime), and cancels the rest by
transitioning them to BatchStateSeedlingCancelled. On exit (status 0)
the operator restarts tapd normally and migration 000060 applies
against the now-clean database. The default posture is still "refuse
to start" -- the daemon never silently mutates state on its own; the
operator must explicitly invoke the repair flag. Cancellations are
state transitions rather than row deletions, so cancelled batches
remain on disk for later inspection.
A re-run of the minting caretaker's Confirmed branch fires
SendMintEvent again for each minted asset; until now this produced a
fresh supply_update_events row per re-fire because the table held no
uniqueness constraint on event content. The downstream supply-commit
state machine then saw the same logical mint twice and double-counted
the update log.

Add a deterministic 32-byte content hash column event_key on
supply_update_events with hash = sha256(group_key
|| be32(update_type_id) || event_data), guarded by a unique index. The
InsertSupplyUpdateEvent query becomes an ON CONFLICT DO NOTHING upsert
so a re-insert of the same logical event is silently absorbed. Two
migrations: 000061 adds the column nullable plus the unique index
(NULLs are distinct in both backends, so pre-backfill rows don't
collide); 000062 is a programmatic step that computes hashes for
pre-existing rows (SQLite lacks a native SHA-256). After 000062 the
index enforces dedup across the whole table. LatestMigrationVersion
bumps from 60 to 62.
In the Committed branch of BatchCaretaker.stateStep,
Wallet.ImportTaprootOutput ran after CommitSignedGenesisTx, which is
the state-transition write that flips the batch from Committed to
Broadcast. A crash between the two left the genesis transaction
persisted (state=Broadcast) but the minting taproot output never
imported into lnd's wallet. On restart, the Broadcast branch executes,
which does not import the output, so lnd remained unaware of it
indefinitely.

Move the import to before the state-transition write. The import is
already idempotent (the "already exists" error is caught and
swallowed), so a crash anywhere in this branch now resumes from
Committed and re-runs the sign-import-commit sequence cleanly.
The minting caretaker's Committed branch reloads an unsigned PSBT
from disk on restart and re-signs it; the resulting signed bytes are
implicitly compared with the prior attempt through downstream side
effects. lnd uses BIP-340 RFC-6979 deterministic Schnorr nonces, so
the property should hold by construction -- but it is load-bearing
for restart semantics and worth checking in CI rather than assuming.

Add an itest that calls Wallet.SignAndFinalizePsbt twice on the same
unsigned PSBT and asserts byte-identical output.
testBasicAssetCreation pins the "restart at every observable
boundary" mint case in one fixed order. A regression that only fires
under a particular interleaving of restart points would slip past it
and would not shrink to a small reproducer.

Add a rapid property test that models the legal BatchState
transition graph and samples every subset of two well-synchronized
restart points (after disk-state Committed; after the Broadcast
branch's publish call), asserting the mint flow always ends in
BatchStateFinalized with no errors and no leftover caretakers. The
harness is structured so a third restart point can be added
trivially.
MintingBatch.Copy and the FundedMintAnchorPsbt.Copy it delegated to
were "deep" in name only. Seedlings, AssetMetas, GenesisPacket's
nested slices, and RootAssetCommitment all shared substructure with
the source. RPC payloads and subscriber notifications used Copy
believing they had a snapshot, when they actually held a smaller
window into the same data -- callers reasoned as if they had
isolation, with no actual barrier.

Walk every reachable pointer, slice, map, and byte buffer and
duplicate it. Helpers cover the leaf types (Genesis, GroupKey,
ScriptKey, MetaReveal, etc.); RootAssetCommitment goes through
commitment.TapCommitment.Copy (already exercised by
TestTapCommitmentDeepCopy); FundedMintAnchorPsbt.Copy round-trips the
psbt.Packet through Serialize/NewFromRawBytes, which duplicates every
nested PInput / POutput / Unknown without hand-rolling per-type
copies. TestMintingBatchCopyIsDeep builds a fully-populated batch,
snapshots it, mutates every reachable substructure on the source,
and asserts the snapshot is unmoved. Impossible error paths panic
rather than fall back to shared pointers.
The cancel protocol used two shared per-caretaker channels
(CancelReqChan + CancelRespChan). Matching a response to its specific
request relied on the gardener serializing all cancel calls -- a
discipline, not a property of the protocol itself.

Replace the pair with a single CancelReqChan carrying cancelReq
values, each embedding its own per-call reply channel. The binding is
now intrinsic and no longer depends on caller serialization.
CancelRespChan is dropped from BatchCaretakerConfig as a side-effect.
The finalize-batch state-request handler returned the caretaker's
live MintingBatch pointer once BroadcastCompleteChan fired. The
caretaker goroutine continues to mutate the batch after broadcast
(Broadcast -> Confirmed -> Finalized writes GenesisPacket and
RootAssetCommitment), so the caller's reads raced those writes.

Take a deep snapshot via Batch.Copy before resolving, matching what
every other state-request handler in the same select arm already
does. Document the ownership invariant on
BatchCaretakerConfig.Batch: the live pointer is owned by the
caretaker goroutine; external observers must Copy first.
Two latent issues in the caretaker. BatchCaretaker.anchorOutputIndex
was a uint32 field populated only by the Frozen state step; on any
restart that re-entered the state machine at Committed, Broadcast, or
Confirmed, the Frozen step never ran and downstream reads used the
zero value. The bug was latent because FundPsbt(..., -1) typically
leaves the asset anchor at output 0. Separately, the
confirmation-watching goroutine launched in the Broadcast step read
CancelReqChan in parallel with the cultivator's main loop, giving
each per-call request two potential receivers; today every
post-broadcast Cancel() rejects, but the per-call respCh contract is
undermined if that ever changes.

Remove the cached field and read AssetAnchorOutIdx directly from
GenesisPacket at each use site; the value is always present once the
batch has progressed past Frozen. Drop the inner CancelReqChan reads
so the cultivator loop is the sole reader post-broadcast, and
document the single-reader invariant on the field.
jtobin added 23 commits July 1, 2026 18:39
ChainPlanter was the only implementation; no mocks exist anywhere
in the repo, and tests already hold *ChainPlanter directly. The
interface added abstraction without any second implementation to
abstract over.

Replace the two config-field types that held the interface with
*tapgarden.ChainPlanter, delete the interface and its compile-time
assertion, and strip the stale "NOTE: This is part of the Planter
interface" comments. If a fake minter is ever needed, the interface
can be reintroduced from the concrete type's actual surface.
tapdb's persistence path for the supply pre-commit row was coupled
to tapgarden's FundedMintAnchorPsbt.PreCommitmentOutput. Extracting
the supply-commit code into its own package would require breaking
that coupling first.

Introduce tapgarden.PreCommitBindData, a typed persistence payload.
The five BatchStore methods that bind a funded genesis transaction
or seal a batch -- CommitMintingBatch, CommitBatchTx,
CommitBatchFunding, AddSproutsToBatch, SealBatch -- gain an
fn.Option[PreCommitBindData] parameter that tapdb writes alongside
its usual columns. For now the planter constructs the payload by
lifting it off FundedMintAnchorPsbt.PreCommitmentOutput via a new
PreCommitBindData helper; the same SQL writes happen in the same
transactions. Two inner helpers (upsertPreCommit + insertMintAnchorTx)
merge into a single upsertPreCommitRow that takes the typed payload
plus the genesis tx hash.
The supply-commit code needs to read mint_supply_pre_commits rows
(for intake checks and verifier lookups) but importing all of
AssetMintingStore just for that surface drags every other minting
concern along with it.

Create a dedicated SupplyPreCommitStore backed by the same db handle
as AssetMintingStore, exposing the mint_supply_pre_commits reads
(currently just FetchDelegationKey) that the supply-commit code
needs. AssetMintingStore.FetchDelegationKey becomes a thin shim that
delegates to it; the shim survives for now because the
MintingRefReader interface in tapgarden still has the method, and
that method is removed in a later commit once the augmenter takes
over delegation-key prep. The write side stays on AssetMintingStore
because pre-commit rows must land atomically with the batch's chain
update, which only the binding tx in AssetMintingStore can offer.
tapgarden's minting flow has supply-commit logic hardcoded
throughout (seedling delegation-key prep, universe-commitment intake
validation, pre-commitment output stamping, PreCommitBindData
production, post-confirmation events). The two concerns are
entangled across the planter and cultivator, and the supply-commit
code cannot move to its own package until tapgarden's call sites
stop reaching directly into it.

Introduce tapgarden.GenesisTxAugmenter, an interface tapgarden will
invoke at well-defined lifecycle moments to let an external
participant act on batch minting without tapgarden knowing the
details. Six hooks: PrepareSeedling and ValidateSeedling for intake;
ExtraOutputs and PostFund for genesis-tx contributions; BindData for
the typed persistence payload; OnBatchConfirmed for downstream
events. A NoOpAugmenter is provided so call sites don't nil-check.
universe/supplycommit gains a concrete GenesisAugmenter that
captures every prior supply-commit-specific tapgarden path in one
place. The augmenter is not yet wired into GardenKit; that happens
in the following commit.
With GenesisTxAugmenter defined but unwired, tapgarden still
executed the legacy supply-commit code paths directly. The
indirection only earns its keep once production paths route through
it.

Wire the augmenter through GardenKit and replace the planter's
direct supply-commit references with augmenter calls at every
lifecycle moment: seedling intake goes through PrepareSeedling
instead of prepSeedlingDelegationKey; the intake gate through
ValidateSeedling instead of validateUniCommitment plus
validateDelegationKey; funding consults ExtraOutputs to splice
additional anchor outputs and PostFund to stamp PSBT metadata;
persistence payloads at funding and seal come from BindData;
post-confirmation events go through OnBatchConfirmed. The planter
and cultivator each gain an augmenter() helper that returns the
configured augmenter or NoOpAugmenter{}, so call sites never
nil-check. MintingBatch.validateGroupAnchor is exported as
ValidateGroupAnchor so the augmenter can invoke it.
tapgarden/mock.go gets a local mockSupplyCommitAugmenter that
mirrors the real one without the dependency cycle, so existing
tests still get fully-formed funded batches.
With GenesisTxAugmenter wired through and tapgarden's production
paths routed through it, the legacy supply-commit types and helpers
in tapgarden are dead code.

Delete them: MintSupplyCommitter and the matching GardenKit fields
(MintSupplyCommitter, DelegationKeyChecker); PreCommitmentOutput, its
Copy method, NewPreCommitmentOutput, and the PreCommitmentOutput
field on FundedMintAnchorPsbt; PreCommitTxOut (moved to supplycommit
and exported there); the DelegationKey alias;
fetchDelegationKey / fetchPreCommitGroupKey;
prepSeedlingDelegationKey; sealBatchPreCommit; the
validateUniCommitment / validateDelegationKey methods on
MintingBatch; the sendSupplyCommitEvents Cultivator method;
FetchDelegationKey on MintingRefReader; the
AssetMintingStore.FetchDelegationKey shim and PreCommitmentOutput
reconstruction in marshalMintingBatch; the PreCommitOutIdx tracking
in AnchorTxOutputIndexes. tapconfig wires a real
supplycommit.GenesisAugmenter into GardenKit. The obsolete
supply_commit_test.go is replaced by a unit test on the augmenter at
universe/supplycommit/genesis_augmenter_test.go;
TestValidateUniCommitment is removed since the invariants now live
on the augmenter. tapgarden/mock.go gains a MockBindDataForBatch
helper so tapdb tests can derive a typed payload without going
through the deleted method.
GenGroupVerifier, GenGroupAnchorVerifier, and
GenRawGroupAnchorVerifier construct closures over
tapnode.GroupFetcher that feed proof.VerifierCtx; they sit naturally
alongside tapnode.GenHeaderVerifier rather than inside the minting
state machine. Verifier consumption is downstream of tapgarden's
end (a verifiable asset in the local store); the generators belong
to the proof-verifier code, not the minting one.

Move the three generators to tapnode along with
ErrGroupKeyUnknown, ErrGenesisNotGroupAnchor, and the LRU cache
helpers. Call sites are updated accordingly.
The cultivator's batch-confirmation handler assembled universe.Item
values inline (storeMintingProof returned both the proof blob and
the item) and streamed them to a local universe via
UpsertProofLeafBatch. The planter's re-org handler had its own
UpsertProofLeaf loop with the same wire-shape construction. Both
sites encode universe publication, which is downstream of
tapgarden's natural end (a verifiable asset in the local store).

Introduce tapgarden.MintProofPublisher, owned by the consumer, with
PublishMintBatch and PublishMintProofUpdates. The
universe/mintpublish package provides the BatchRegistrar-backed
implementation. tapgarden now passes raw
(asset, proof, mintTxHash, outIdx) inputs and never constructs a
universe.Item, BaseLeafKey, Identifier, Leaf, or GenesisWithGroup
itself. GardenKit loses the Universe and UniversePushBatchSize
fields in favor of a single MintProofPublisher field;
NoOpMintProofPublisher covers test configurations with no downstream
universe.
The bug this PR closes -- restart-triggered re-fires of mint events
inserting duplicate supply_update_events rows -- could have already
fired against an unupgraded database. Two rows with identical content
hash to the same event_key, so the second SetSupplyUpdateEventKey in
the migration 62 backfill would violate the unique index added in
migration 61 and abort the migration.

Track hashes seen during the backfill loop and delete any row whose
hash a prior row already claimed. The deleted rows are by definition
the same logical event as the one that survives. A new
DeleteSupplyUpdateEvent sqlc query supports the delete-by-event_id
that the dedupe needs.

TestMigration62BackfillDedupesLegacyDuplicates seeds three identical
rows pre-backfill and asserts the migration leaves exactly one with
the expected hash, never erroring out.
Address golangci-lint findings (lll, gofmt, whitespace, exhaustive,
gosec, govet copylocks) and two reviewer comments: use uint for
mintpublish's batchSize so the negative-value case is ruled out at
the type boundary, and use the already-computed batchKey in the
Cultivator.Cancel default arm instead of re-deriving it from the
live batch.

The copylocks fixes switch the augmenter staging-batch construction
from `*batch` to `batch.Copy()` so the atomic state field isn't
copied by value. The exhaustive cases get explicit default arms on
the BatchState switches in checkSingletonInvariant and RunRepairTool.
Rename Migration62BackfillSupplyUpdateEventKeys to
Migration62BackfillEventKeys so the aligned map entry fits the
80-column limit.

Make TestMigration62BackfillSupplyUpdateEventKeys and
TestMigration62BackfillDedupesLegacyDuplicates portable to Postgres:
the first one used the SQLite-only `?` placeholder, the second used
MAX(bytea) which isn't defined in Postgres. Switch to sqlc's
InsertSupplyUpdateEvent and split COUNT + key lookup into two
queries. Regenerate sqlc bindings whose source-comment references
the event_key column's migration number.

Add a [repair] section to sample-tapd.conf with an example for
repair.cancel-duplicate-batches; the sample-conf check requires every
tapd flag to have a default or example in the file.
Main introduced 000060_asset_transfers_superseded, colliding with
this branch's 000060_unique_pending_or_frozen_batch. Renumber this
branch's migrations and all their narrative references:

  60 unique_pending_or_frozen_batch     -> 61
  61 dedupe_supply_update_events        -> 62
  62 backfill_supply_update_event_keys  -> 63

LatestMigrationVersion bumps to 63 and the Migration62BackfillEventKeys
constant becomes Migration63BackfillEventKeys.
Legacy databases affected by the pre-hardening planter can hold two
kinds of state that trip the invariants this branch introduces:

  1. Duplicate rows in {Pending, Frozen} (from a startup race the
     planter no longer allows). Migration 61's naive `CREATE UNIQUE
     INDEX` would fail on these DBs with an opaque SQL error and
     force the operator into a manual `tapd --repair.cancel-duplicate-batches`
     step before the daemon would start again.

  2. Multiple `supply_update_events` rows with identical content,
     split between rows attached to a finalized transition and
     dangling rows (from restart re-fires of the same logical
     event). Migration 63's backfill dedup kept the first row by
     physical order, which could delete the attached row and leave a
     finalized transition without its events.

Migration 61 now cancels all but the most recent pre-broadcast batch
inside the same migration transaction before creating the index, so
affected DBs migrate cleanly without operator intervention.
`tapd --repair.cancel-duplicate-batches` is retained as a diagnostic
that surfaces the same repair outside the migration stream.

Migration 63's fetch query now orders attached rows before dangling
ones (tie-broken by event_id ASC), so the backfill's first-wins dedup
is guaranteed to preserve any attached row when duplicates exist.

Migration 62's down.sql picks up a warning that down-migrating past
62 is destructive because 63 physically removed duplicate rows.
The universe_commitments column on asset_minting_batches is set at
batch creation from the seedling's SupplyCommitments intent. Under
the previous BindMintingBatchWithTx query the funding step
overwrote it with `preCommit.IsSome()`; a NoOpAugmenter (or any
deployment where the augmenter chose not to emit a bind payload
for a batch that legitimately requested supply commitments) would
silently flip the flag off at funding, and the mint would proceed
without pre-commit anchoring even though the operator had asked
for it.

Drop the column from the SET clause so the flag is authoritative
from NewMintingBatch onward and cannot regress at funding time.

Also add ORDER BY precommits.id ASC to FetchMintSupplyPreCommits
so SupplyPreCommitStore.FetchDelegationKey's fetchRow[0] pick is
deterministic when a legacy DB somehow holds divergent pre-commit
rows for a single group.
RunRepairTool ran under context.Background(), so a Ctrl+C
mid-repair would leave partial state on disk (some duplicate
batches cancelled, others not). Take the shutdownInterceptor as a
parameter and derive a cancellable context from its shutdown
channel.

Also switch from sort.Slice to sort.SliceStable for the
CreationTime tie-break so two batches with identical timestamps
produce a deterministic winner, matching the ordering rules
migration 61's self-heal now uses inside the migration stream.
Four failure modes ride the same state machine and were addressable
together on the cultivator/planter side:

  1. OnBatchConfirmed doc/code split. The interface said errors are
     logged and do not roll back the confirmation, but the code
     returned the error, skipping MarkBatchConfirmed. That left the
     batch stuck in Broadcast forever while the publish and
     augmenter side-effects had already fired, so a restart re-runs
     everything non-idempotently. Now log-and-continue, which
     matches the interface and relies on migration 62's event_key
     dedup for augmenter re-runs.

  2. Cancel/dead-cultivator deadlock. cancelMintingBatch could find
     a cultivator in the planter's map, push into its CancelReqChan
     (buffered), then wait forever on the reply channel because the
     cultivator's main goroutine had already stopped reading
     CancelReqChan (blocked in SignalCompletion, or past the
     post-broadcast select). Add a Done() channel on Cultivator,
     closed once assetCultivator returns, and select on it in
     cancelMintingBatch so a late cancel surfaces an actionable
     "already completed" error instead of hanging until Quit. Also
     buffer completionSignals so SignalCompletion never blocks the
     goroutine while the gardener is inside a stateReq closure.

  3. Startup race vs the singleton pre-broadcast index. Start()
     launches cultivators for resumed Frozen batches, then starts
     the gardener. A seedling arriving before the cultivator has
     moved its batch past Frozen would trip the partial unique
     index added by migration 61 with a raw SQL error. Guard the
     "no pending batch" case in addSeedling by refusing the
     seedling if any cultivator still holds its batch in
     {Pending, Frozen}, with a clear user-facing error.

  4. Two-truth split in the Frozen and Committed branches.
     RootAssetCommitment/GenesisPacket.Pkt/ChainFees were set on the
     live b.cfg.Batch before their DB writes, so a concurrent
     Copy() (e.g. from FinalizeBatch) could observe the in-memory
     batch ahead of what is on disk -- the exact hazard commit
     caaa621 was supposed to close. Stage the mutations on a
     staging copy (for the augmenter and derived scripts), pass
     locals into the store calls, then apply the mutations to the
     live batch only once the DB write returns. The Copy() panic
     on RootAssetCommitment.Copy is no longer reachable.
Small footguns exposed by the confirmation-path work above:

  - NoOpAugmenter.ValidateSeedling now rejects any seedling that
    requests SupplyCommitments. Without a real augmenter wired
    there is no substance to emit the pre-commit anchor output or
    the post-confirmation mint event, and silently accepting the
    seedling let the batch proceed with supply-commit disabled
    even though the caller had asked for it.

  - PreCommitBindData grows a NewPreCommitBindData constructor
    that enforces `InternalKey.PubKey != nil` at the boundary.
    The zero value would nil-deref in the store's upsert path;
    the augmenter and mocks now route through the constructor.

  - GenesisAugmenter.OnBatchConfirmed logs the error from
    HasDelegationKey before dropping the asset from mint-event
    emission. Matches main's log-then-drop behavior; the previous
    silent drop hid legitimate store errors as if the asset just
    lacked delegation.

  - mintpublish.NewPublisher panics if batchSize == 0. The
    PublishMintBatch loop advances by batchSize, so a zero would
    have spun forever; a wiring-time invariant deserves an
    upfront panic.

  - SupplyPreCommitStore.FetchDelegationKey's fallback pick is now
    documented as "lowest precommits.id first"; the query gained
    the matching ORDER BY in the previous commit.
Cancel() writes to the buffered respCh (size 1) and then returns,
which triggers the goroutine's `defer close(b.done)`. Both cases
become ready simultaneously in the planter's select, and Go picks
randomly -- so a successful cancel could still surface the
"already completed" error path even though the batch was
cancelled and persisted correctly. The impact was mild (state on
disk was correct, retries would refuse the already-cancelled
batch) but the error surface misled callers.

Nest a non-blocking respCh receive inside the Done() case so a
queued reply is always preferred over the goroutine's exit
signal.
The repair tool's SliceStable claimed it inherited a deterministic
tie-break from FetchNonFinalBatches' input order, but the
underlying query had no ORDER BY. Two pre-broadcast batches with
identical CreationTime therefore had an arbitrary winner --
opposite of the reason SliceStable was chosen in the first place.

Give the query the same explicit tie-break migration 61 uses --
creation_time_unix DESC, batch_id DESC -- so the repair tool
lands on the same "most recent" row the migration would
preserve, and update the misleading comment.

assertGenesisPsbtFinalized in planter_test.go picked "the last
non-cancelled batch" via fn.Last, relying on the pre-existing
undefined order to place the newest at the tail. Under the new
newest-first ordering it becomes fn.First. No behavioral change
in the test's intent, just alignment with the guaranteed order.
The prior log-and-continue behavior claimed confirmation completion
without the cause of completion being present: OnBatchConfirmed is
the site where supply-commit mint events are durably recorded, and
suppressing its error advanced the batch past the retry point while
still owing the event. The event_key dedup index (migration 62)
exists specifically to make the augmenter retry idempotent, but
the mint lifecycle -- the only agent positioned to fire that retry
-- had just been severed from the retry by the log-and-continue.

Return the error, so the batch stays in BatchStateBroadcast and the
confirmation branch re-runs on restart. Universe publish above is
already idempotent; the event_key dedup covers the augmenter side;
MarkBatchConfirmed is what advances state on disk, so retry is
safe.

Pin the invariant with a new test that wires an injectable augmenter
into the planter harness, forces OnBatchConfirmed to fail, asserts
the batch is not advanced past Broadcast, then flips the augmenter
to succeed, restarts the planter, and asserts the batch advances
to Finalized cleanly on the retry.
Two golangci-lint findings against `--new-from-rev=main` on the
hardening branch:

  * planter.go:2914 exhaustive: the pre-broadcast-batch guard's
    switch only cares about Pending and Frozen. Add an empty
    default arm so the exhaustive linter treats the omission as
    intentional.

  * cultivator.go:889 lll: the FundedPsbt struct literal ran to
    87 chars on the ChangeOutputIndex line. Hoist the value into
    a local so the field assignment fits within 80 chars.
The parameter was previously only used in a log message; the loop
terminated on the fixpoint check `nextState == currentState` and
ignored the caller's declared target entirely. That's a name-vs-
behavior split: callers reading the signature reasonably expect
targetState to be a contract, but the function delivered a
fixpoint-until-you-stop with no assertion that the fixpoint reached
matched the target.

Keep the fixpoint check as the primary exit -- it's the correct
model for this state machine, since the terminal states
self-transition (Broadcast waits, Finalized is idle) -- and add a
post-loop assertion that the fixpoint we reached matches the
declared target. A mismatch now surfaces as an error instead of a
silent success, catching either a caller passing the wrong target
or a state-machine change under our feet.

Rewrite the doc comment to describe what the function actually
does and what targetState now means.
BatchStateConfirmed on main names two different essences with the
same word: an *input state* for the Confirmed branch of the state
machine, and a *mid-branch durable checkpoint* written by
MarkBatchConfirmed. The Confirmed branch published universe proofs,
emitted the supply-commit event, called MarkBatchConfirmed (which
advanced disk state to Confirmed), and then registered the anchor
tx with the re-org watcher via WatchProofs. A crash between
MarkBatchConfirmed and WatchProofs left disk at Confirmed with no
re-org callback registered by us; on restart the fast-forward
skipped the branch entirely, and the re-org watcher's own Start-
time recovery re-registered the anchor tx with its
DefaultUpdateCallback -- silently dropping the universe re-publish
that updateMintingProofs performs on any subsequent re-org.

Dissolve the identity conflation by making MarkBatchConfirmed the
LAST persistence write in the branch. WatchProofs now runs first;
disk-Confirmed genuinely means "every step the Confirmed branch
owes is done." A crash before MarkBatchConfirmed keeps the batch
at Broadcast so the whole branch re-runs on restart -- universe
publish is idempotent, augmenter side is deduped by event_key
(migration 62), storeMintingProof rides upserts, and WatchProofs
against a fresh watcher instance is a first-time registration.

Also rewrite the surrounding comments to describe the new
ordering, note the semantic justification for the restart fast-
forward, and drop the stale TODO on the Finalized case ("confirmed
should just be the final state?") -- that concern is fully
resolved by the new ordering.

Add a ShouldFail atomic.Bool hook to MockProofWatcher and a
testWatchProofsFailureAbortsConfirmation test that forces
WatchProofs to fail, asserts the batch stays at Broadcast (under
the old ordering this assertion would fail because
MarkBatchConfirmed would have run before the WatchProofs failure),
restarts with the mock succeeding, and asserts the batch advances
to Finalized. Pins the ordering invariant against regression.
@jtobin

jtobin commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

To be obviated by a series of smaller PR's, starting with the linked one.

@jtobin jtobin closed this Jul 2, 2026
@github-project-automation github-project-automation Bot moved this from 🆕 New to ✅ Done in Taproot-Assets Project Board Jul 2, 2026
@jtobin jtobin modified the milestones: v0.8.2, v0.9 Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

2 participants