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

Closed
vnprc-agent wants to merge 27 commits from agent/epoch-reward-trigger into master
Collaborator

The mint now rotates epochs by itself: it watches the chain, and a block reward landing on its configured receive address closes the current epoch and opens the next, provisionally at detection and final at D confirmations, with reorgs handled by dissolve or same-height re-mine. Epoch state lives in the mint's own database and is written in the same transaction as the cdk state it pairs with; the JSON file store is gone. All five regtest scenarios from docs/EPOCH_DESIGN.md were run on a live dev stack against the final branch and pass. Three money-affecting defects, one route-closure regression and one chronology bug were found only by running or by cold review and are fixed here. Review the transactional ordering in finalize/dissolve, the chronology helper, and the route closure first.

What changed

  • Chain watcher (roles/mint/src/lib/epoch/mod.rs): one task polls bitcoind. Each tick fetches the tip once, rolls the watermark back to the newest retained block the node still agrees with (entries above the tip are never canonical and cost no RPC call), walks forward block by block matching coinbase outputs against the receive script by script bytes, then runs the finality pass. The watermark and a trailing window of block hashes persist with the records, so a restart re-scans from where it stopped and rewards mined while the mint was down open and close epochs in sequence.
  • Boundary state machine: a reward opens the epoch Provisional; at tip - height + 1 >= confirmation_depth with the boundary still canonical it becomes Final, its quotes are paid and the previous epoch's quote creation is retired (retire_payment_processor, never deregister). A boundary orphaned with no replacement payment dissolves: its never-paid, never-issued quotes are re-stamped onto the previous epoch and paid; a re-mined block at the same height that still pays the mint keeps the epoch and swaps the hash. A reorg across a final reward boundary is logged at error and left alone (accepted residual risk, per the design).
  • Persistence: epoch records and the watcher state are two values in cdk's key-value store inside mint.sqlite (namespace hashpool/epochs, keys records and watcher), read with the database's KV API and written through the mint transaction. finalize pays each quote with Mint::pay_mint_quote(tx, …) and flips the record in the same transaction; open_epoch and the watermark writes go through one helper that owns the transaction, commits on success and awaits an explicit rollback on error. Durability is sqlite's; just clean cashu is the clean slate; an epochs.json left from a pre-merge build is refused at startup on a fresh database and warned about on resume.
  • Chronology is by (height, insertion index), not insertion order, because a reorg rescan can open a lower-height epoch after still-provisional higher ones; the previous-epoch lookup, the resumable chain and the finality order all use that helper.
  • Quote path (quote_processing.rs): holds a read guard on the current epoch across quote creation and the pay decision; finalize, dissolve and open take the write side while the store lock is held, so a quote can never be created against a provisional view and miss the bulk-pay, be created in a unit mid-dissolve, or see current published out of commit order.
  • Config ([hashpool_mint]): receive_address (required; dev config ships the P2WPKH regtest address of the generator point, pinned by a test; prod ships it commented out so an unedited prod config refuses to start), confirmation_depth (dev 3, prod 6), poll_interval_secs (dev 1, prod 10). epoch_store_path no longer exists.
  • RPC (roles-utils/rpc): getblockhash, getblock (verbosity 1) and getrawtransaction by hex block hash, beside the existing methods.
  • Scenario runner scripts/epoch-scenarios.sh <1|3|4|5> against a running stack, reading the records from the database; scenario 2 is hand-driven.

Decisions worth knowing

  • Bulk-pay pays every unpaid quote in the finalized unit, and the HTTP route that creates ehash quotes is closed. cdk-axum mounts POST /v1/mint/quote/{method} for every custom method, so without the closure anyone could create an unpaid quote for any amount and have it paid for free at finality. Only ehash quote creation is closed: a middleware refuses exactly the decoded, case-folded four-segment path /v1/mint/quote/ehash, because ehash quotes are created solely by the pool over SV2 and an HTTP-created one is by definition not backed by a share. Standard quote creation for bitcoin units (/v1/mint/quote/bolt11, bolt12) and every other mint route are untouched, so an operator can serve sats beside ehash. The NUT-XX pubkey lookup (/v1/mint/quote/pubkey) and the NUT-29 batch check (/v1/mint/quote/ehash/check) share the prefix and must keep working, which a broader rule broke on the live stack. The two halves are documented at both sites and must move together.
  • A state flip persists only with, or after, the payment it implies. finalize pays and flips in one transaction. dissolve is two-step by necessity: cdk has no transaction method to change a quote's unit, so the re-stamp is a direct UPDATE mint_quote SET unit through cdk's own SQL layer (only rows with amount_paid = 0 AND amount_issued = 0, affected count checked against the quotes counted under the lock), followed by one transaction that pays the re-stamped quotes in the previous epoch and flips the record. A crash between the two is recovered by the next tick, which re-stamps zero rows and pays. vnprc/hashpool#40 tracks the cdk method that makes this one transaction. The dissolved keyset must have issued nothing; any violation logs critical invariant violation and stops the watcher while the mint keeps serving.
  • sqlite facts the code is shaped by: one writer at a time, so every read that would need a second connection (quote listing, total_issued, the re-stamp) runs before our transaction opens, still under the locks; and cdk rolls a dropped transaction back on a background task, so every error path awaits rollback() explicitly or the next transaction hits "database is locked".
  • Manual rotation is refused while the current epoch is provisional (409), decided inside open_epoch under the store lock. A reward at the height of a hash-less Final record (manual or genesis) still opens its own provisional epoch (hash_<pool>_<H>_1), since only reward records carry a boundary.
  • Genesis is an unbacked Final epoch at the current height by design; rewards from before the mint existed are not epochs, and the watcher starts scanning above the genesis height. A pre-merge database with no watcher state would start at the tip, logged at warn.

Risk

R3. Persistent state in the mint database (two new KV values; no cdk table changes), money paths (pay-and-flip, re-stamp), and a watcher that runs unattended. Rollback is a revert plus restart; the KV values are additive and the previous code never read them. What a reviewer can still catch: an ordering in finalize/dissolve/open_epoch that lets a write escape the lock or the transaction, or any path that reaches deregister_payment_processor.

Validation

  • cargo test -p mint -p rpc_sv2: 62 + 5 + 2 pass, including pure tests for the block planner (incl. the hash-less Final case), finality selection, rollback point bounded by the tip, chronology ordering with the reviewer's reorg sequence at both store and manager level, KV store round-trips and dropped-transaction isolation, finalize and dissolve against in-memory and file-backed mints, the failed-pay retry paths, the manual-rotation refusal, the epochs.json guard, and the route classifier (blocked and allowed forms).
  • Live dev stack, three full runs (JSON build, tip-bounded fix, database build), the last against the final code with D=60 and a fresh database: (1) one reward → one provisional record with the right unit and subsidy, final at D, previous unit retired, 21 quotes created unpaid in the window and paid at finality, swept; (2) pool coinbase at the mint, 25 won blocks → 25 provisional epochs with strictly increasing heights, finalized oldest-first each retiring its predecessor, 0 unpaid, 0 unissued, 23 wallet units; (3) three rewards mined with the mint stopped → opened in order on restart, finalized in order; (4) dissolve with 25 quotes → re-stamped and paid within 2 s of the invalidate; (5) same-height re-mine by script, plus a restart that resumed from the database with the resumable chain; and a chronology witness: two provisional epochs, reorg below both, a paying block at a lower height on the new branch → every orphaned epoch dissolved into the lower epoch's unit and all 20 re-stamped quotes paid at its finality. Zero watcher tick errors. The migration guard refused a start over a pre-merge epochs.json. Route forms ehash, %65hash, EHASH, //…/ → 404; pubkey lookup, batch check, status, mint and swap reachable.
  • Found only by running: the watcher wedged on a chain shorter than its retained window; the route closure was bypassable by percent-encoding and case; a widened closure blocked the sweeper's endpoints; the mint compiled against sqlite's single-writer rule and background rollback (deadlocks in the test suite until reordered).
  • Cold reviews (pi, provider nullsink, model gpt-5.6-sol), three passes on successive diffs. Fixed: route bypass; finality persisting before payment and resume suppressing a failed pay (pay-before-flip, then pay-with-flip); manual-rotation race; dead-code mirror of the tested rollback helper; chronology by insertion order; current published after the store lock; hash-less Final record suppressing a reward; stale epochs.json ignored silently; comment narration. Discarded with reasons: "dev bcrt1 address checksum invalid" (the node accepts it, the runs used it); "watermark should start at the last epoch height" and "genesis should inspect the tip's coinbase" (design: genesis is unbacked and rewards before the mint are not epochs).

Follow-ups, not in this PR

  • vnprc/hashpool#40: cdk transaction method to change a quote's unit, making dissolve one transaction and deleting the raw re-stamp.
  • vnprc/hashpool#38 and #39: refuse dust coinbase outputs as rewards; disable job declaration for the MVP.
  • The dev stack starts the mint before regtest-setup mines its first blocks, so a fresh checkout's genesis epoch sits at height 0; a devenv ordering fix.
  • finalize and resume list every mint quote; fine at today's scale, worth a unit-filtered query before quote counts grow large.

Closes vnprc/hashpool#18. Closes vnprc/hashpool#36 (superseded: the store moved into the database, which meets its goals by construction). Refs vnprc/hashpool#13.

The mint now rotates epochs by itself: it watches the chain, and a block reward landing on its configured receive address closes the current epoch and opens the next, provisionally at detection and final at D confirmations, with reorgs handled by dissolve or same-height re-mine. Epoch state lives in the mint's own database and is written in the same transaction as the cdk state it pairs with; the JSON file store is gone. All five regtest scenarios from `docs/EPOCH_DESIGN.md` were run on a live dev stack against the final branch and pass. Three money-affecting defects, one route-closure regression and one chronology bug were found only by running or by cold review and are fixed here. Review the transactional ordering in `finalize`/`dissolve`, the chronology helper, and the route closure first. ## What changed - **Chain watcher** (`roles/mint/src/lib/epoch/mod.rs`): one task polls `bitcoind`. Each tick fetches the tip once, rolls the watermark back to the newest retained block the node still agrees with (entries above the tip are never canonical and cost no RPC call), walks forward block by block matching coinbase outputs against the receive script by script bytes, then runs the finality pass. The watermark and a trailing window of block hashes persist with the records, so a restart re-scans from where it stopped and rewards mined while the mint was down open and close epochs in sequence. - **Boundary state machine**: a reward opens the epoch `Provisional`; at `tip - height + 1 >= confirmation_depth` with the boundary still canonical it becomes `Final`, its quotes are paid and the previous epoch's quote creation is retired (`retire_payment_processor`, never deregister). A boundary orphaned with no replacement payment dissolves: its never-paid, never-issued quotes are re-stamped onto the previous epoch and paid; a re-mined block at the same height that still pays the mint keeps the epoch and swaps the hash. A reorg across a final reward boundary is logged at error and left alone (accepted residual risk, per the design). - **Persistence**: epoch records and the watcher state are two values in cdk's key-value store inside `mint.sqlite` (namespace `hashpool`/`epochs`, keys `records` and `watcher`), read with the database's KV API and written through the mint transaction. `finalize` pays each quote with `Mint::pay_mint_quote(tx, …)` and flips the record in the same transaction; `open_epoch` and the watermark writes go through one helper that owns the transaction, commits on success and awaits an explicit rollback on error. Durability is sqlite's; `just clean cashu` is the clean slate; an `epochs.json` left from a pre-merge build is refused at startup on a fresh database and warned about on resume. - **Chronology** is by `(height, insertion index)`, not insertion order, because a reorg rescan can open a lower-height epoch after still-provisional higher ones; the previous-epoch lookup, the resumable chain and the finality order all use that helper. - **Quote path** (`quote_processing.rs`): holds a read guard on the current epoch across quote creation and the pay decision; finalize, dissolve and open take the write side while the store lock is held, so a quote can never be created against a provisional view and miss the bulk-pay, be created in a unit mid-dissolve, or see `current` published out of commit order. - **Config** (`[hashpool_mint]`): `receive_address` (required; dev config ships the P2WPKH regtest address of the generator point, pinned by a test; prod ships it commented out so an unedited prod config refuses to start), `confirmation_depth` (dev 3, prod 6), `poll_interval_secs` (dev 1, prod 10). `epoch_store_path` no longer exists. - **RPC** (`roles-utils/rpc`): `getblockhash`, `getblock` (verbosity 1) and `getrawtransaction` by hex block hash, beside the existing methods. - **Scenario runner** `scripts/epoch-scenarios.sh <1|3|4|5>` against a running stack, reading the records from the database; scenario 2 is hand-driven. ## Decisions worth knowing - **Bulk-pay pays every unpaid quote in the finalized unit, and the HTTP route that creates ehash quotes is closed.** cdk-axum mounts `POST /v1/mint/quote/{method}` for every custom method, so without the closure anyone could create an unpaid quote for any amount and have it paid for free at finality. Only ehash quote creation is closed: a middleware refuses exactly the decoded, case-folded four-segment path `/v1/mint/quote/ehash`, because ehash quotes are created solely by the pool over SV2 and an HTTP-created one is by definition not backed by a share. Standard quote creation for bitcoin units (`/v1/mint/quote/bolt11`, `bolt12`) and every other mint route are untouched, so an operator can serve sats beside ehash. The NUT-XX pubkey lookup (`/v1/mint/quote/pubkey`) and the NUT-29 batch check (`/v1/mint/quote/ehash/check`) share the prefix and must keep working, which a broader rule broke on the live stack. The two halves are documented at both sites and must move together. - **A state flip persists only with, or after, the payment it implies.** `finalize` pays and flips in one transaction. `dissolve` is two-step by necessity: cdk has no transaction method to change a quote's unit, so the re-stamp is a direct `UPDATE mint_quote SET unit` through cdk's own SQL layer (only rows with `amount_paid = 0 AND amount_issued = 0`, affected count checked against the quotes counted under the lock), followed by one transaction that pays the re-stamped quotes in the previous epoch and flips the record. A crash between the two is recovered by the next tick, which re-stamps zero rows and pays. vnprc/hashpool#40 tracks the cdk method that makes this one transaction. The dissolved keyset must have issued nothing; any violation logs `critical invariant violation` and stops the watcher while the mint keeps serving. - **sqlite facts the code is shaped by**: one writer at a time, so every read that would need a second connection (quote listing, `total_issued`, the re-stamp) runs before our transaction opens, still under the locks; and cdk rolls a dropped transaction back on a background task, so every error path awaits `rollback()` explicitly or the next transaction hits "database is locked". - **Manual rotation is refused while the current epoch is provisional** (409), decided inside `open_epoch` under the store lock. A reward at the height of a hash-less Final record (manual or genesis) still opens its own provisional epoch (`hash_<pool>_<H>_1`), since only reward records carry a boundary. - **Genesis is an unbacked Final epoch at the current height by design**; rewards from before the mint existed are not epochs, and the watcher starts scanning above the genesis height. A pre-merge database with no watcher state would start at the tip, logged at warn. ## Risk R3. Persistent state in the mint database (two new KV values; no cdk table changes), money paths (pay-and-flip, re-stamp), and a watcher that runs unattended. Rollback is a revert plus restart; the KV values are additive and the previous code never read them. What a reviewer can still catch: an ordering in `finalize`/`dissolve`/`open_epoch` that lets a write escape the lock or the transaction, or any path that reaches `deregister_payment_processor`. ## Validation - `cargo test -p mint -p rpc_sv2`: 62 + 5 + 2 pass, including pure tests for the block planner (incl. the hash-less Final case), finality selection, rollback point bounded by the tip, chronology ordering with the reviewer's reorg sequence at both store and manager level, KV store round-trips and dropped-transaction isolation, finalize and dissolve against in-memory and file-backed mints, the failed-pay retry paths, the manual-rotation refusal, the `epochs.json` guard, and the route classifier (blocked and allowed forms). - Live dev stack, three full runs (JSON build, tip-bounded fix, database build), the last against the final code with D=60 and a fresh database: (1) one reward → one provisional record with the right unit and subsidy, final at D, previous unit retired, 21 quotes created unpaid in the window and paid at finality, swept; (2) pool coinbase at the mint, 25 won blocks → 25 provisional epochs with strictly increasing heights, finalized oldest-first each retiring its predecessor, 0 unpaid, 0 unissued, 23 wallet units; (3) three rewards mined with the mint stopped → opened in order on restart, finalized in order; (4) dissolve with 25 quotes → re-stamped and paid within 2 s of the invalidate; (5) same-height re-mine by script, plus a restart that resumed from the database with the resumable chain; and a chronology witness: two provisional epochs, reorg below both, a paying block at a lower height on the new branch → every orphaned epoch dissolved into the lower epoch's unit and all 20 re-stamped quotes paid at its finality. Zero watcher tick errors. The migration guard refused a start over a pre-merge `epochs.json`. Route forms `ehash`, `%65hash`, `EHASH`, `//…/` → 404; pubkey lookup, batch check, status, mint and swap reachable. - Found only by running: the watcher wedged on a chain shorter than its retained window; the route closure was bypassable by percent-encoding and case; a widened closure blocked the sweeper's endpoints; the mint compiled against sqlite's single-writer rule and background rollback (deadlocks in the test suite until reordered). - Cold reviews (pi, provider nullsink, model gpt-5.6-sol), three passes on successive diffs. Fixed: route bypass; finality persisting before payment and resume suppressing a failed pay (pay-before-flip, then pay-with-flip); manual-rotation race; dead-code mirror of the tested rollback helper; chronology by insertion order; `current` published after the store lock; hash-less Final record suppressing a reward; stale `epochs.json` ignored silently; comment narration. Discarded with reasons: "dev `bcrt1` address checksum invalid" (the node accepts it, the runs used it); "watermark should start at the last epoch height" and "genesis should inspect the tip's coinbase" (design: genesis is unbacked and rewards before the mint are not epochs). ## Follow-ups, not in this PR - vnprc/hashpool#40: cdk transaction method to change a quote's unit, making dissolve one transaction and deleting the raw re-stamp. - vnprc/hashpool#38 and #39: refuse dust coinbase outputs as rewards; disable job declaration for the MVP. - The dev stack starts the mint before regtest-setup mines its first blocks, so a fresh checkout's genesis epoch sits at height 0; a devenv ordering fix. - `finalize` and resume list every mint quote; fine at today's scale, worth a unit-filtered query before quote counts grow large. Closes vnprc/hashpool#18. Closes vnprc/hashpool#36 (superseded: the store moved into the database, which meets its goals by construction). Refs vnprc/hashpool#13.
Cuts narration, restated names, and references to review rounds across
epoch/mod.rs, epoch/store.rs, and main.rs, per writing.md's code-comment
rule. Keeps only what a caller relies on, a trap the obvious alternative
falls into, or a pointer to EPOCH_DESIGN.md.
Removes NUM_KEYS, coinbase_reward, run_finality_pass, BlockAction, and
admin_router docs that only restated the item's name; trims finalize,
dissolve, and resync to their contracts and traps; applies the same cut
to store.rs and main.rs field/method docs.
Author
Collaborator

Superseded by vnprc/hashpool#41: the same final tree (byte-identical, verified with git diff), rebuilt from 27 commits into four self-contained ones, each compiling and passing its tests alone, with a plain-English description. Review there.

Superseded by vnprc/hashpool#41: the same final tree (byte-identical, verified with `git diff`), rebuilt from 27 commits into four self-contained ones, each compiling and passing its tests alone, with a plain-English description. Review there.
vnprc-agent closed this pull request 2026-10-11 03:14:24 +01:00

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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!37
No description provided.