mint: automatic reward trigger with reorg-safe epoch boundaries #37
Loading…
Reference in a new issue
No description provided.
Delete branch "agent/epoch-reward-trigger"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.mdwere 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 infinalize/dissolve, the chronology helper, and the route closure first.What changed
roles/mint/src/lib/epoch/mod.rs): one task pollsbitcoind. 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.Provisional; attip - height + 1 >= confirmation_depthwith the boundary still canonical it becomesFinal, 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).mint.sqlite(namespacehashpool/epochs, keysrecordsandwatcher), read with the database's KV API and written through the mint transaction.finalizepays each quote withMint::pay_mint_quote(tx, …)and flips the record in the same transaction;open_epochand 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 cashuis the clean slate; anepochs.jsonleft from a pre-merge build is refused at startup on a fresh database and warned about on resume.(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_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 seecurrentpublished out of commit order.[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_pathno longer exists.roles-utils/rpc):getblockhash,getblock(verbosity 1) andgetrawtransactionby hex block hash, beside the existing methods.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
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.finalizepays and flips in one transaction.dissolveis two-step by necessity: cdk has no transaction method to change a quote's unit, so the re-stamp is a directUPDATE mint_quote SET unitthrough cdk's own SQL layer (only rows withamount_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 logscritical invariant violationand stops the watcher while the mint keeps serving.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 awaitsrollback()explicitly or the next transaction hits "database is locked".open_epochunder 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.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_epochthat lets a write escape the lock or the transaction, or any path that reachesderegister_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, theepochs.jsonguard, and the route classifier (blocked and allowed forms).epochs.json. Route formsehash,%65hash,EHASH,//…/→ 404; pubkey lookup, batch check, status, mint and swap reachable.currentpublished after the store lock; hash-less Final record suppressing a reward; staleepochs.jsonignored silently; comment narration. Discarded with reasons: "devbcrt1address 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
finalizeand 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.
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.Pull request closed