mint: automatic reward trigger with reorg-safe epoch boundaries #41

Merged
vnprc merged 4 commits from agent/reward-trigger into master 2026-10-11 03:27:13 +01:00
Collaborator

Until now someone had to tell the mint by hand when a new mining epoch starts. With this change the mint watches the Bitcoin chain itself: when a block reward lands on the mint's address, that block is the boundary, the old epoch closes and a new one opens. Epoch state lives inside the mint's own database, written in the same transaction as the quote payments it goes with. The five regtest scenarios from docs/EPOCH_DESIGN.md all pass on a live dev stack against this exact code.

How it works

The watcher. A background loop wakes every second (ten in prod). It asks the node for the current height, checks that the blocks it already saw are still on the chain, reads any new blocks, and looks at each coinbase for a payment to the mint's address. That is the only kind of payment it reacts to.

Provisional epochs. A fresh block can still be orphaned, so a new epoch starts in a waiting state. Shares mined meanwhile still earn quotes, but the quotes stay unpaid. Once the boundary block has enough confirmations (confirmation_depth: 3 in dev, 6 in prod) the epoch becomes final and all its quotes are paid at once. Nobody gets spendable ecash backed by a block that could still disappear.

Reorgs. If the boundary block is replaced by another block at the same height that also pays the mint, the epoch is kept and points at the new block. If it is replaced by a block that does not pay the mint, the epoch is dissolved: its unpaid quotes move back into the previous epoch and are paid there, since the work was real even though the block was not. Before that happens the mint checks that the dissolved epoch's keyset never issued anything; if it has, the watcher stops and logs a critical error rather than guess.

Where the state lives. In cdk's key-value table inside mint.sqlite, under the hashpool namespace: one value for the epoch records, one for the watcher's position. Finalizing an epoch pays its quotes and marks it final in a single transaction, so a crash leaves the database either fully before or fully after. There is no side file. (A leftover epochs.json from a pre-merge build is refused on a fresh database and warned about otherwise; just clean cashu is the clean slate.)

One hole closed. cdk serves an HTTP route that lets anyone create an ehash quote for any amount. Since finalizing pays every unpaid quote in the epoch, that would be free money, so that one route is now refused. Only ehash is affected: ehash quotes are created solely by the pool over SV2. Quote creation for bitcoin units (bolt11, bolt12) and every other route, including the quote lookup and batch check the proxy depends on, stay open, so an operator can serve sats beside ehash.

The manual lever. rotate-epoch still works but refuses while an epoch is provisional, so it cannot stomp on a boundary the watcher is tracking.

Known limits

  • A reorg deeper than the confirmation depth after ecash has been issued is accepted risk, the same as any pool's confirmation policy.
  • Dissolve moves quotes between units with a direct SQL update because cdk has no transaction method for it, so dissolve is two steps with a safe retry instead of one transaction. vnprc/hashpool#40 asks cdk for that method.
  • The first epoch at startup (genesis) is unbacked by design; rewards from before the mint existed are not epochs.
  • Finalize and resume list every quote in the mint; fine today, worth a filtered query before volumes grow.

Risk

R3: persistent state in the mint database and money paths, running unattended. Rollback is a revert plus restart; the two stored values are additive and old code never read them. What a reviewer can still catch: a write in finalize, dissolve or open_epoch that escapes the lock or the transaction, or any path that reaches deregister_payment_processor (closing an epoch must retire, never deregister, or its paid quotes become unmintable).

How it was checked

  • Unit tests: 62 in the mint plus 5 integration and 2 RPC tests, covering the block planner, finality selection, the tip-bounded rollback, chronology under reorg, store round-trips and transaction isolation, finalize and dissolve including failed-pay retries, the manual-rotation refusal, the epochs.json guard, and the route classifier (blocked ehash forms, allowed everything else).
  • Live regtest stack, three runs as the branch evolved, the last on this exact code: single reward (quotes created unpaid, paid at finality, swept by the proxy); the pool mining into its own address (25 won blocks, 25 epochs, finalized in order, nothing left unpaid); catching up after the mint was down; dissolve with quotes (re-stamped and paid within two seconds); same-height re-mine; a reorg below two provisional epochs with a lower-height replacement reward (all orphaned epochs dissolved into the right unit); resume from the database after restart. Zero watcher errors.
  • Three cold reviews by a model that did not write the code. They found the route-closure bypass by percent-encoding, the finality-before-payment ordering, the manual-rotation race, the chronology bug, and the current publish race; all fixed. Two findings were wrong and are recorded as such in the commit history and in vnprc/notes/hashpool/epoch-reward-trigger-validation.md, which has the full run logs.

Closes vnprc/hashpool#18. Closes vnprc/hashpool#36 (superseded: the store moved into the database). Refs vnprc/hashpool#13.

Until now someone had to tell the mint by hand when a new mining epoch starts. With this change the mint watches the Bitcoin chain itself: when a block reward lands on the mint's address, that block is the boundary, the old epoch closes and a new one opens. Epoch state lives inside the mint's own database, written in the same transaction as the quote payments it goes with. The five regtest scenarios from `docs/EPOCH_DESIGN.md` all pass on a live dev stack against this exact code. ## How it works **The watcher.** A background loop wakes every second (ten in prod). It asks the node for the current height, checks that the blocks it already saw are still on the chain, reads any new blocks, and looks at each coinbase for a payment to the mint's address. That is the only kind of payment it reacts to. **Provisional epochs.** A fresh block can still be orphaned, so a new epoch starts in a waiting state. Shares mined meanwhile still earn quotes, but the quotes stay unpaid. Once the boundary block has enough confirmations (`confirmation_depth`: 3 in dev, 6 in prod) the epoch becomes final and all its quotes are paid at once. Nobody gets spendable ecash backed by a block that could still disappear. **Reorgs.** If the boundary block is replaced by another block at the same height that also pays the mint, the epoch is kept and points at the new block. If it is replaced by a block that does not pay the mint, the epoch is dissolved: its unpaid quotes move back into the previous epoch and are paid there, since the work was real even though the block was not. Before that happens the mint checks that the dissolved epoch's keyset never issued anything; if it has, the watcher stops and logs a critical error rather than guess. **Where the state lives.** In cdk's key-value table inside `mint.sqlite`, under the `hashpool` namespace: one value for the epoch records, one for the watcher's position. Finalizing an epoch pays its quotes and marks it final in a single transaction, so a crash leaves the database either fully before or fully after. There is no side file. (A leftover `epochs.json` from a pre-merge build is refused on a fresh database and warned about otherwise; `just clean cashu` is the clean slate.) **One hole closed.** cdk serves an HTTP route that lets anyone create an ehash quote for any amount. Since finalizing pays every unpaid quote in the epoch, that would be free money, so that one route is now refused. Only ehash is affected: ehash quotes are created solely by the pool over SV2. Quote creation for bitcoin units (`bolt11`, `bolt12`) and every other route, including the quote lookup and batch check the proxy depends on, stay open, so an operator can serve sats beside ehash. **The manual lever.** `rotate-epoch` still works but refuses while an epoch is provisional, so it cannot stomp on a boundary the watcher is tracking. ## Known limits - A reorg deeper than the confirmation depth after ecash has been issued is accepted risk, the same as any pool's confirmation policy. - Dissolve moves quotes between units with a direct SQL update because cdk has no transaction method for it, so dissolve is two steps with a safe retry instead of one transaction. vnprc/hashpool#40 asks cdk for that method. - The first epoch at startup (genesis) is unbacked by design; rewards from before the mint existed are not epochs. - Finalize and resume list every quote in the mint; fine today, worth a filtered query before volumes grow. ## Risk R3: persistent state in the mint database and money paths, running unattended. Rollback is a revert plus restart; the two stored values are additive and old code never read them. What a reviewer can still catch: a write in `finalize`, `dissolve` or `open_epoch` that escapes the lock or the transaction, or any path that reaches `deregister_payment_processor` (closing an epoch must retire, never deregister, or its paid quotes become unmintable). ## How it was checked - Unit tests: 62 in the mint plus 5 integration and 2 RPC tests, covering the block planner, finality selection, the tip-bounded rollback, chronology under reorg, store round-trips and transaction isolation, finalize and dissolve including failed-pay retries, the manual-rotation refusal, the `epochs.json` guard, and the route classifier (blocked ehash forms, allowed everything else). - Live regtest stack, three runs as the branch evolved, the last on this exact code: single reward (quotes created unpaid, paid at finality, swept by the proxy); the pool mining into its own address (25 won blocks, 25 epochs, finalized in order, nothing left unpaid); catching up after the mint was down; dissolve with quotes (re-stamped and paid within two seconds); same-height re-mine; a reorg below two provisional epochs with a lower-height replacement reward (all orphaned epochs dissolved into the right unit); resume from the database after restart. Zero watcher errors. - Three cold reviews by a model that did not write the code. They found the route-closure bypass by percent-encoding, the finality-before-payment ordering, the manual-rotation race, the chronology bug, and the `current` publish race; all fixed. Two findings were wrong and are recorded as such in the commit history and in `vnprc/notes/hashpool/epoch-reward-trigger-validation.md`, which has the full run logs. Closes vnprc/hashpool#18. Closes vnprc/hashpool#36 (superseded: the store moved into the database). Refs vnprc/hashpool#13.
Adds a chain watcher that opens a provisional epoch when a reward pays
the mint's receive script, finalizes it once the boundary reaches
confirmation_depth, and dissolves it (re-stamping unpaid quotes onto the
previous epoch) if the boundary is orphaned first. Epoch records and the
watcher's scan position live in cdk's key-value store inside the mint's
own database, under the hashpool/epochs namespace, and every state change
that pairs with a cdk change (such as paying a quote at finality) commits
in the same database transaction. Closing an epoch retires its
quote-creation entry rather than deregistering it, so quotes already paid
in that unit can still be issued after the epoch ends.

Refs vnprc/hashpool#18.
Bulk-pay at finality pays every unpaid quote in the unit being finalized,
so an HTTP-created ehash quote would mint for free. Only the exact
`/v1/mint/quote/ehash` path is refused; bolt11/bolt12 quote creation and
the pubkey lookup and batch-check routes stay open.
vnprc approved these changes 2026-10-11 03:27:06 +01:00
vnprc merged commit 92dddda27f into master 2026-10-11 03:27:13 +01:00
vnprc deleted branch agent/reward-trigger 2026-10-11 03:27:13 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
vnprc/hashpool!41
No description provided.