mint epoch mechanics: height-named units, records, manual rotation lever #24

Open
vnprc-agent wants to merge 5 commits from agent/epoch-mechanics into agent/nutxx-pubkey-lookup
Collaborator

The mint now owns mining epochs: on first boot it opens a genesis epoch at the current chain height, every epoch is its own currency unit named hash_<pool>_<height> with its own keyset, quotes stamp the current epoch's unit, and just rotate-epoch closes the current epoch and opens the next by hand. No trigger yet — rotation is manual/genesis only (the reward watcher is #18); no wallet changes (the multi-unit sweeper is #17, a sibling branch). The legacy static hash unit is gone from mint setup — units are runtime state now, so this is a clean-slate change for dev databases (just clean cashu). If it is wrong, revert; master's static-unit mint is untouched by this branch until merge.

Implements #16. Part of #13. Stacked on #21 (pubkey-lookup); pins cdk at the dynamic-registration branch (see Validation for the push dependency).

Design anchors

docs/EPOCH_DESIGN.md — this PR implements: the model (naming incl. deterministic collision suffix, mint-owned identity, genesis-at-startup, one-epoch-per-(pool,height)), the quote lifecycle branch (final epoch → pay at creation, exactly the historical behavior; provisional epoch → created unpaid, pays at finality — dormant until #18 since genesis/manual epochs are final immediately), retire-previous-epoch-at-successor-finality, and the 7-day quote TTL. Epoch records live in a file-backed store (epochs.json beside the mint DB) — single writer, atomic rename, the last non-dissolved record is current.

Risk

  • cdk pin moves to the dynamic payment-processor registration branch (register/deregister on a running mint). Until the owner pushes that branch to vnprc/cdk, this PR is reviewable but not buildable by others; the pin is the content-addressed hash of the prepared local branch, so an unmodified push makes it build with no further changes.
  • Mint startup now requires bitcoind RPC (genesis and manual rotation read the chain height; 60s retry then fail-loud) and a configured pool_pubkey — both fail loud if missing. Prod config carries placeholder TODOs for the operator.
  • Removed a latent footgun in setup: the old boot-time NUT-04 settings restore would have wiped runtime-registered epoch entries on every restart, stranding the resumed epoch's quote creation. NUT-29 refresh retained.
  • The admin lever binds loopback (127.0.0.1:3339 default) — rotation is not reachable off-box.

Validation

  • cargo check -p mint clean at the final pin; cargo test -p mint 17+5 tests green (new epoch tests: pool-pubkey validation incl. case normalization and uncompressed rejection, suffix determinism, store round-trip/current-selection/dissolved-exclusion/height-counting).
  • Reduced smoke, bitcoind + mint binary on a fresh regtest chain (full-stack sweep across units is #17's witness): genesis epoch opened at live chain height 5 (hash_<pool>_5, source=genesis, final); two same-height manual rotations produced _5_1 and _5_2 (the deterministic suffix rule); after three more blocks, rotation produced _8; epochs.json records all four with correct height/unit/state/source; mint restart logs resuming persisted epoch … height=8 with no re-genesis; /v1/keysets lists all four epoch units, each keyset active within its own unit (old epochs stay mintable for outstanding quotes by design — quote creation for them is what retires).
  • Smoke-harness note, not a branch defect: the mint's listen_host = "localhost" binds IPv6 ::1 on this machine, so probes must use localhost, not 127.0.0.1 (the rest of the dev stack also uses localhost, which is why this was never visible). The admin lever binds 127.0.0.1 explicitly.
  • Not validated here: sweeping across units (that is #17's witness, stacked as a sibling PR), provisional-boundary behavior (#18).
The mint now owns mining epochs: on first boot it opens a genesis epoch at the current chain height, every epoch is its own currency unit named `hash_<pool>_<height>` with its own keyset, quotes stamp the current epoch's unit, and `just rotate-epoch` closes the current epoch and opens the next by hand. No trigger yet — rotation is manual/genesis only (the reward watcher is #18); no wallet changes (the multi-unit sweeper is #17, a sibling branch). The legacy static `hash` unit is gone from mint setup — units are runtime state now, so this is a clean-slate change for dev databases (`just clean cashu`). If it is wrong, revert; master's static-unit mint is untouched by this branch until merge. Implements #16. Part of #13. Stacked on #21 (pubkey-lookup); pins cdk at the dynamic-registration branch (see Validation for the push dependency). ## Design anchors `docs/EPOCH_DESIGN.md` — this PR implements: the model (naming incl. deterministic collision suffix, mint-owned identity, genesis-at-startup, one-epoch-per-(pool,height)), the quote lifecycle branch (final epoch → pay at creation, exactly the historical behavior; provisional epoch → created unpaid, pays at finality — dormant until #18 since genesis/manual epochs are final immediately), retire-previous-epoch-at-successor-finality, and the 7-day quote TTL. Epoch records live in a file-backed store (`epochs.json` beside the mint DB) — single writer, atomic rename, the last non-dissolved record is current. ## Risk - **cdk pin moves** to the dynamic payment-processor registration branch (register/deregister on a running mint). Until the owner pushes that branch to vnprc/cdk, this PR is reviewable but not buildable by others; the pin is the content-addressed hash of the prepared local branch, so an unmodified push makes it build with no further changes. - **Mint startup now requires bitcoind RPC** (genesis and manual rotation read the chain height; 60s retry then fail-loud) and a configured `pool_pubkey` — both fail loud if missing. Prod config carries placeholder TODOs for the operator. - **Removed a latent footgun in setup**: the old boot-time NUT-04 settings restore would have wiped runtime-registered epoch entries on every restart, stranding the resumed epoch's quote creation. NUT-29 refresh retained. - The admin lever binds loopback (`127.0.0.1:3339` default) — rotation is not reachable off-box. ## Validation - `cargo check -p mint` clean at the final pin; `cargo test -p mint` 17+5 tests green (new epoch tests: pool-pubkey validation incl. case normalization and uncompressed rejection, suffix determinism, store round-trip/current-selection/dissolved-exclusion/height-counting). - Reduced smoke, bitcoind + mint binary on a fresh regtest chain (full-stack sweep across units is #17's witness): genesis epoch opened at live chain height 5 (`hash_<pool>_5`, source=genesis, final); two same-height manual rotations produced `_5_1` and `_5_2` (the deterministic suffix rule); after three more blocks, rotation produced `_8`; `epochs.json` records all four with correct height/unit/state/source; mint restart logs `resuming persisted epoch … height=8` with no re-genesis; `/v1/keysets` lists all four epoch units, each keyset active within its own unit (old epochs stay mintable for outstanding quotes by design — quote *creation* for them is what retires). - Smoke-harness note, not a branch defect: the mint's `listen_host = "localhost"` binds IPv6 `::1` on this machine, so probes must use `localhost`, not `127.0.0.1` (the rest of the dev stack also uses `localhost`, which is why this was never visible). The admin lever binds `127.0.0.1` explicitly. - Not validated here: sweeping across units (that is #17's witness, stacked as a sibling PR), provisional-boundary behavior (#18).
Author
Collaborator

Adversarial review findings (read-only pass; fixes in flight on this branch):

BLOCKER — restart never re-registers the resumed epoch's processor. roles/mint/src/lib/epoch/mod.rs resume branch returns without register_payment_processor; the cdk processor map is in-memory only, so after any restart with an existing epochs.json, every MintQuoteRequest fails "Unit unsupported" until a manual rotation. First boot works (genesis registers); the smoke's restart check read only DB-backed surfaces (resume log, /v1/keysets) so this was invisible. Secondary: first rotation after restart can't deregister the previous unit (not in the map), so stale NUT-04/05 entries accumulate. Fix: resume path re-registers via a helper shared with open_epoch, treating duplicate-register as success.

should-fixjust clean cashu (the PR's prescribed clean-slate step) leaves epochs.json behind: fresh DB + stale store = resumed epoch with no keyset/settings, quoting dead, manual rm to recover. Clean must remove epochs.json (+ .json.tmp).

should-fix — the derivation-collision retry is dead code: the match is contains("collision") but cdk's error displays "collided" (UnitStringCollision). A real 31-bit collision would brick genesis/rotation for that (pool,height) instead of suffixing. Match the typed variant; cap the loop.

should-fix — rotation deregisters the previous unit before swapping current; a quote handled in that window reads the old unit and gets "Unit unsupported" (share bounced, pool doesn't retry). Narrow under manual rotation, armed the moment #18 automates it. Also matches the design doc better to retire at successor finality: reorder to persist + swap before deregister.

should-fix (latent, armed by #18) — pay-or-defer does two separate reads (unit at creation, finality after): a rotation between them strands a final-epoch quote unpaid forever. Needs one snapshot read returning (unit, is_final).

should-fixopen_epoch failure ordering: persist failure leaves memory/disk diverged and current pointing at a just-deregistered unit; crash between register and persist leaves settings naming a unit the store doesn't know. Same reorder + rollback the in-memory push on persist failure.

should-fix — the "60s retry then fail-loud" genesis claim holds only when bitcoind fails fast: MiniRpcClient has no request timeout, so a hung RPC blocks boot forever, silently. Wrap each attempt in a timeout.

nits — admin lever is unauthenticated and loopback-by-convention (an 0.0.0.0 typo exposes rotation; just rotate-epoch hardcodes the port, ignoring config); prod config ships the generator-point placeholder as a valid pool_pubkey (consider refusing it at startup); store persist has no fsync (power loss can truncate the file; parse failure on boot; deleting it silently re-genesises), and blocking fs calls run under the async mutex.

Body-truth: test counts and smoke narrative reproduced exactly; "old quotes stay mintable" verified true at the pin (cdk e583e73c mints from recorded payments); "clean-slate via just clean cashu" is FALSE as shipped (second finding); the cdk pin is now fetchable from github (the owner pushed), so the buildability caveat is moot. Merging #24 without #23 leaves dev-stack sweeping dead (epoch-unit quotes vs hash-unit wallet) — disclosed, but merge them together.

Verdict: design and rotation mechanics sound, body honest; not mergeable until the blocker + clean-cashu fix land. Fix commits follow on this branch.

Adversarial review findings (read-only pass; fixes in flight on this branch): **BLOCKER — restart never re-registers the resumed epoch's processor.** `roles/mint/src/lib/epoch/mod.rs` resume branch returns without `register_payment_processor`; the cdk processor map is in-memory only, so after any restart with an existing `epochs.json`, every MintQuoteRequest fails "Unit unsupported" until a manual rotation. First boot works (genesis registers); the smoke's restart check read only DB-backed surfaces (resume log, /v1/keysets) so this was invisible. Secondary: first rotation after restart can't deregister the previous unit (not in the map), so stale NUT-04/05 entries accumulate. Fix: resume path re-registers via a helper shared with `open_epoch`, treating duplicate-register as success. **should-fix** — `just clean cashu` (the PR's prescribed clean-slate step) leaves `epochs.json` behind: fresh DB + stale store = resumed epoch with no keyset/settings, quoting dead, manual `rm` to recover. Clean must remove `epochs.json` (+ `.json.tmp`). **should-fix** — the derivation-collision retry is dead code: the match is `contains("collision")` but cdk's error displays "collided" (`UnitStringCollision`). A real 31-bit collision would brick genesis/rotation for that (pool,height) instead of suffixing. Match the typed variant; cap the loop. **should-fix** — rotation deregisters the previous unit *before* swapping `current`; a quote handled in that window reads the old unit and gets "Unit unsupported" (share bounced, pool doesn't retry). Narrow under manual rotation, armed the moment #18 automates it. Also matches the design doc better to retire at successor finality: reorder to persist + swap before deregister. **should-fix (latent, armed by #18)** — pay-or-defer does two separate reads (unit at creation, finality after): a rotation between them strands a final-epoch quote unpaid forever. Needs one snapshot read returning (unit, is_final). **should-fix** — `open_epoch` failure ordering: persist failure leaves memory/disk diverged and `current` pointing at a just-deregistered unit; crash between register and persist leaves settings naming a unit the store doesn't know. Same reorder + rollback the in-memory push on persist failure. **should-fix** — the "60s retry then fail-loud" genesis claim holds only when bitcoind fails fast: MiniRpcClient has no request timeout, so a hung RPC blocks boot forever, silently. Wrap each attempt in a timeout. **nits** — admin lever is unauthenticated and loopback-by-convention (an `0.0.0.0` typo exposes rotation; `just rotate-epoch` hardcodes the port, ignoring config); prod config ships the generator-point placeholder as a *valid* pool_pubkey (consider refusing it at startup); store persist has no fsync (power loss can truncate the file; parse failure on boot; deleting it silently re-genesises), and blocking fs calls run under the async mutex. Body-truth: test counts and smoke narrative reproduced exactly; "old quotes stay mintable" verified true at the pin (cdk `e583e73c` mints from recorded payments); "clean-slate via `just clean cashu`" is FALSE as shipped (second finding); the cdk pin is now fetchable from github (the owner pushed), so the buildability caveat is moot. Merging #24 without #23 leaves dev-stack sweeping dead (epoch-unit quotes vs `hash`-unit wallet) — disclosed, but merge them together. Verdict: design and rotation mechanics sound, body honest; not mergeable until the blocker + clean-cashu fix land. Fix commits follow on this branch.
Author
Collaborator

Review findings addressed in the commit above: restart re-registration (blocker) — witnessed by a fresh-process resume against existing epoch state plus a successful post-restart rotation (_8_1, previous unit retired); just clean cashu now removes epochs.json; collision retry matches the typed UnitStringCollision with a 32-attempt cap; rotation persists and swaps current before deregistering the previous unit; the quote path takes one snapshot read for unit + pay decision; store append rolls back memory on persist failure; RPC attempts have a 10s hard timeout. Not addressed (recorded nits): admin-lever auth/binding hardening, generator-point placeholder refusal in prod config, store fsync. cargo check clean; 17+5 tests green.

Review findings addressed in the commit above: restart re-registration (blocker) — witnessed by a fresh-process resume against existing epoch state plus a successful post-restart rotation (`_8_1`, previous unit retired); `just clean cashu` now removes `epochs.json`; collision retry matches the typed `UnitStringCollision` with a 32-attempt cap; rotation persists and swaps current before deregistering the previous unit; the quote path takes one snapshot read for unit + pay decision; store append rolls back memory on persist failure; RPC attempts have a 10s hard timeout. Not addressed (recorded nits): admin-lever auth/binding hardening, generator-point placeholder refusal in prod config, store fsync. cargo check clean; 17+5 tests green.
Restart re-registers the resumed epoch (review blocker); clean cashu removes
epochs.json; typed UnitStringCollision match with attempt cap; persist and
swap current before retiring the previous unit; single snapshot read for the
quote path pay decision; store append rolls back on persist failure; RPC
attempts carry a hard timeout.
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin agent/epoch-mechanics:agent/epoch-mechanics
git switch agent/epoch-mechanics

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch agent/nutxx-pubkey-lookup
git merge --no-ff agent/epoch-mechanics
git switch agent/epoch-mechanics
git rebase agent/nutxx-pubkey-lookup
git switch agent/nutxx-pubkey-lookup
git merge --ff-only agent/epoch-mechanics
git switch agent/epoch-mechanics
git rebase agent/nutxx-pubkey-lookup
git switch agent/nutxx-pubkey-lookup
git merge --no-ff agent/epoch-mechanics
git switch agent/nutxx-pubkey-lookup
git merge --squash agent/epoch-mechanics
git switch agent/nutxx-pubkey-lookup
git merge --ff-only agent/epoch-mechanics
git switch agent/nutxx-pubkey-lookup
git merge agent/epoch-mechanics
git push origin agent/nutxx-pubkey-lookup
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!24
No description provided.