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

Closed
vnprc-agent wants to merge 16 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-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.

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.
Author
Collaborator

Blocker found while rebuilding this PR's lost cdk dependency: as designed, epoch retirement would permanently strand outstanding paid ehash quotes.

The defect. This branch retires the previous epoch by calling deregister_payment_processor(prev_unit, ehash_method()) at roles/mint/src/lib/epoch/mod.rs:223. In cdk, check_mint_quote_payments (crates/cdk/src/mint/ln.rs:28-32) short-circuits on quote state only for bolt11:

if quote.payment_method.is_bolt11()
    && (state == MintQuoteState::Issued || state == MintQuoteState::Paid)
{
    return Ok(());
}

let ln = match payment_processors.get(&PaymentProcessorKey::new(
    quote.unit.clone(), quote.payment_method.clone(),
)) {
    Some(ln) => ln,
    None => return Err(Error::UnsupportedUnit),
};

Epochs use PaymentMethod::Custom("ehash"), not bolt11, so a paid-but-unissued ehash quote falls through to the processor-map lookup and gets UnsupportedUnit once its epoch's processor is deregistered. The quote becomes permanently unmintable: miner rewards that were earned and paid for but can never be issued. Every epoch rotation would do this to whatever was still outstanding in the closing epoch.

Two things worth noting beyond the defect. docs/EPOCH_DESIGN.md and this PR body both state that old epochs stay mintable for outstanding quotes and that quote creation is what retires; under the behaviour above that claim was false. And this PR's smoke validation established it by reading /v1/keysets, which proves the keysets still exist but says nothing about whether minting against them succeeds — the check was narrower than the claim it was supporting. The underlying cdk behaviour is pre-existing and was not introduced by the rebuild; the rebuild surfaced it.

The fix, owner-approved. cdk gains a third operation distinct from deregister:

  • retire_payment_processor(unit, method) removes the NUT-04/NUT-05 method settings, so new quote creation for that unit stops, but keeps the processor map entry, so existing quotes stay checkable and mintable. The processor is not stopped.
  • deregister_payment_processor(unit, method) keeps its current full-teardown meaning: settings and map entry removed, processor stopped when nothing else references it.

Epoch retirement then calls retire_payment_processor, which makes the design document's promise true rather than aspirational. The cost is one retained map entry per retired epoch; because every epoch here shares a single processor instance, Arc::ptr_eq dedup means no additional processor is left running.

This PR will be re-pinned onto the rebuilt cdk branch and its rotation path switched from deregister to retire. The accompanying test asserts that a paid, unissued custom-method quote still mints after retirement, and is checked by sabotage — the test must fail if retire is altered to drop the map entry — so it pins the behaviour rather than merely passing.

Context on why the dependency was rebuilt at all: the cdk revision this PR pinned, 3dea7086, is confirmed lost (search recorded on vnprc/hashpool#21). Refs vnprc/hashpool#15. Refs vnprc/hashpool#25.

Blocker found while rebuilding this PR's lost cdk dependency: as designed, epoch retirement would permanently strand outstanding paid ehash quotes. **The defect.** This branch retires the previous epoch by calling `deregister_payment_processor(prev_unit, ehash_method())` at `roles/mint/src/lib/epoch/mod.rs:223`. In cdk, `check_mint_quote_payments` (`crates/cdk/src/mint/ln.rs:28-32`) short-circuits on quote state **only for bolt11**: ```rust if quote.payment_method.is_bolt11() && (state == MintQuoteState::Issued || state == MintQuoteState::Paid) { return Ok(()); } let ln = match payment_processors.get(&PaymentProcessorKey::new( quote.unit.clone(), quote.payment_method.clone(), )) { Some(ln) => ln, None => return Err(Error::UnsupportedUnit), }; ``` Epochs use `PaymentMethod::Custom("ehash")`, not bolt11, so a paid-but-unissued ehash quote falls through to the processor-map lookup and gets `UnsupportedUnit` once its epoch's processor is deregistered. The quote becomes permanently unmintable: miner rewards that were earned and paid for but can never be issued. Every epoch rotation would do this to whatever was still outstanding in the closing epoch. **Two things worth noting beyond the defect.** `docs/EPOCH_DESIGN.md` and this PR body both state that old epochs stay mintable for outstanding quotes and that quote *creation* is what retires; under the behaviour above that claim was false. And this PR's smoke validation established it by reading `/v1/keysets`, which proves the keysets still exist but says nothing about whether minting against them succeeds — the check was narrower than the claim it was supporting. The underlying cdk behaviour is pre-existing and was not introduced by the rebuild; the rebuild surfaced it. **The fix, owner-approved.** cdk gains a third operation distinct from deregister: - `retire_payment_processor(unit, method)` removes the NUT-04/NUT-05 method settings, so new quote creation for that unit stops, but **keeps the processor map entry**, so existing quotes stay checkable and mintable. The processor is not stopped. - `deregister_payment_processor(unit, method)` keeps its current full-teardown meaning: settings and map entry removed, processor stopped when nothing else references it. Epoch retirement then calls `retire_payment_processor`, which makes the design document's promise true rather than aspirational. The cost is one retained map entry per retired epoch; because every epoch here shares a single processor instance, `Arc::ptr_eq` dedup means no additional processor is left running. This PR will be re-pinned onto the rebuilt cdk branch and its rotation path switched from deregister to retire. The accompanying test asserts that a paid, unissued custom-method quote still mints after retirement, and is checked by sabotage — the test must fail if retire is altered to drop the map entry — so it pins the behaviour rather than merely passing. Context on why the dependency was rebuilt at all: the cdk revision this PR pinned, `3dea7086`, is confirmed lost (search recorded on vnprc/hashpool#21). Refs vnprc/hashpool#15. Refs vnprc/hashpool#25.
# Conflicts:
#	devenv.nix
#	justfile
#	protocols/Cargo.toml
#	roles/Cargo.lock
#	roles/Cargo.toml
Moves protocols/Cargo.toml, roles/Cargo.toml [patch.crates-io] and
devenv.nix cdkCommit to the rebuilt dynamic-payment-processor cdk
revision. cdkRepo is unchanged (vnprc/cdk). roles/Cargo.lock still
resolves against the previous rev; see the next commit's validation
notes for why a fresh resolve against this rev cannot complete yet.
deregister_payment_processor drops the processor-map entry, and
cdk's check_mint_quote_payments re-resolves through that map on
every check for every method but bolt11 (crates/cdk/src/mint/ln.rs).
A paid, not-yet-issued ehash quote in the retiring epoch would become
permanently unmintable. retire_payment_processor closes only the
quote-creation gate and keeps the map entry, so outstanding quotes
stay checkable and mintable. Ordering is unchanged: persist and swap
current before retiring the previous unit.
Author
Collaborator

Refactored onto the rebuilt cdk branch, with the stranding fix applied. This PR is not mergeable yet, for one reason that is outside the repository: the cdk revision it pins is not published. Details at the end.

What changed

  • 6c6ead1b merges the updated base branch. Conflicts in exactly five files, all pin and infrastructure: devenv.nix, justfile, protocols/Cargo.toml, roles/Cargo.toml, roles/Cargo.lock. The justfile conflict was two independently added recipe blocks — rotate-epoch from this branch, generate-to/mine from the base — and both were kept. No conflict touched roles/mint/, confirmed by diffing the staged tree for that path.
  • 311953f1 re-pins cdk from e5a85f8 to ed7657c32c2f9cda8af33589ab48be58831b7ac7, the rebuilt payment-processor branch.
  • 8d26afac switches epoch retirement from deregister_payment_processor to retire_payment_processor at roles/mint/src/lib/epoch/mod.rs:223. This is the fix for the stranding bug described in the previous comment. The persist-and-swap-before-retire ordering established by the earlier review and landed in 922637e9 is preserved exactly.

A repo-wide audit found exactly one call site for deregister_payment_processor before the change and none after, so no other path can still strand quotes.

Verified by the integrator, independently of the worker

  • The retire switch is present at epoch/mod.rs:223; deregister_payment_processor appears nowhere in roles/.
  • No occurrence of 3dea7086 or cba8469b remains in the tree.
  • MSRV lock pins intact: time 0.3.41, serde_with 3.17.0, darling 0.21.3, simple_asn1 0.6.3.
  • cdk API signatures are unchanged by the rebuild, so the retire switch was a pure rename with no argument reshaping. register_payment_processor and the typed Error::UnitStringCollision match are both unaffected — in particular the typed match landed in 922637e9 has not regressed to a string comparison.
  • cargo test -p mint: 17 unit plus 5 integration, 22 passed, 0 failed, matching this PR body's claim exactly.
  • On the cdk side the integrator ran the full suite (595 passed) and mutation-tested the guarantee: patching retire_payment_processor to drop the processor map entry makes retire_lets_paid_custom_method_quote_be_minted fail with UnsupportedUnit, which is precisely the stranding condition. The test therefore pins the behaviour rather than merely passing alongside it.

Blocking: the pinned cdk revision is unpublished

ed7657c3 exists only in a local worktree. The agent that built it has no write access to github.com/vnprc/cdk, so the owner must push it. Until then:

  • The committed Cargo.toml and devenv.nix name ed7657c3, which is the correct final state.
  • roles/Cargo.lock still resolves its 16 cdk entries at e5a85f8, because re-resolving against the unpublished rev fails with revspec 'ed7657c3' not found. The lock was deliberately not hand-edited to paper over this.
  • So a bare cargo check on this exact commit fails until the push. That is expected, not a regression.

All gates above were run with the pin URL temporarily redirected to a local clone of the identical revision, never committed. The revision is the same, so the code under test is byte-identical to what GitHub will serve; only the fetch location differed. After the push, the lock needs one re-resolve against the real URL before merge.

Not proven here, stated plainly

The stranding fix is not exercised at the hashpool level. roles/mint/src/lib/epoch/mod.rs has no unit tests of its own and there is no harness lighter than the full stack for driving EpochManager against a live mint and bitcoind. The guarantee is covered by cdk's own tests, verified by mutation there. What remains unproven is the composition: that a real epoch rotation on the dev stack leaves a paid, unissued ehash quote from the closing epoch mintable. That is the end-to-end rotation witness, and it is the right gate to run before epochs rotate automatically under vnprc/hashpool#18.

Pre-existing failure, filed separately

cargo clippy -p mint --all-targets fails with six absurd_extreme_comparisons errors in roles/mint/tests/sv2_connection_integration.rs. That file is byte-identical on master, on this branch, and at 922637e9, and the integrator reproduced the failure on this tree — it is not introduced by this PR and does not block it. Filed as vnprc/hashpool#27, which also records the deeper problem: the flagged assertions compare u8 constants against that type's own bounds, so they cannot fail on any input and verify nothing.

Refs vnprc/hashpool#15. Refs vnprc/hashpool#25.

Refactored onto the rebuilt cdk branch, with the stranding fix applied. **This PR is not mergeable yet, for one reason that is outside the repository: the cdk revision it pins is not published.** Details at the end. ## What changed - `6c6ead1b` merges the updated base branch. Conflicts in exactly five files, all pin and infrastructure: `devenv.nix`, `justfile`, `protocols/Cargo.toml`, `roles/Cargo.toml`, `roles/Cargo.lock`. The `justfile` conflict was two independently added recipe blocks — `rotate-epoch` from this branch, `generate-to`/`mine` from the base — and both were kept. No conflict touched `roles/mint/`, confirmed by diffing the staged tree for that path. - `311953f1` re-pins cdk from `e5a85f8` to `ed7657c32c2f9cda8af33589ab48be58831b7ac7`, the rebuilt payment-processor branch. - `8d26afac` switches epoch retirement from `deregister_payment_processor` to `retire_payment_processor` at `roles/mint/src/lib/epoch/mod.rs:223`. This is the fix for the stranding bug described in the previous comment. The persist-and-swap-before-retire ordering established by the earlier review and landed in `922637e9` is preserved exactly. A repo-wide audit found exactly one call site for `deregister_payment_processor` before the change and none after, so no other path can still strand quotes. ## Verified by the integrator, independently of the worker - The retire switch is present at `epoch/mod.rs:223`; `deregister_payment_processor` appears nowhere in `roles/`. - No occurrence of `3dea7086` or `cba8469b` remains in the tree. - MSRV lock pins intact: `time` 0.3.41, `serde_with` 3.17.0, `darling` 0.21.3, `simple_asn1` 0.6.3. - cdk API signatures are unchanged by the rebuild, so the retire switch was a pure rename with no argument reshaping. `register_payment_processor` and the typed `Error::UnitStringCollision` match are both unaffected — in particular the typed match landed in `922637e9` has not regressed to a string comparison. - `cargo test -p mint`: 17 unit plus 5 integration, 22 passed, 0 failed, matching this PR body's claim exactly. - On the cdk side the integrator ran the full suite (595 passed) and mutation-tested the guarantee: patching `retire_payment_processor` to drop the processor map entry makes `retire_lets_paid_custom_method_quote_be_minted` fail with `UnsupportedUnit`, which is precisely the stranding condition. The test therefore pins the behaviour rather than merely passing alongside it. ## Blocking: the pinned cdk revision is unpublished `ed7657c3` exists only in a local worktree. The agent that built it has no write access to `github.com/vnprc/cdk`, so the owner must push it. Until then: - The committed `Cargo.toml` and `devenv.nix` name `ed7657c3`, which is the correct final state. - `roles/Cargo.lock` still resolves its 16 cdk entries at `e5a85f8`, because re-resolving against the unpublished rev fails with `revspec 'ed7657c3' not found`. The lock was deliberately **not** hand-edited to paper over this. - So a bare `cargo check` on this exact commit fails until the push. That is expected, not a regression. All gates above were run with the pin URL temporarily redirected to a local clone of the identical revision, never committed. The revision is the same, so the code under test is byte-identical to what GitHub will serve; only the fetch location differed. After the push, the lock needs one re-resolve against the real URL before merge. ## Not proven here, stated plainly The stranding fix is **not** exercised at the hashpool level. `roles/mint/src/lib/epoch/mod.rs` has no unit tests of its own and there is no harness lighter than the full stack for driving `EpochManager` against a live mint and bitcoind. The guarantee is covered by cdk's own tests, verified by mutation there. What remains unproven is the composition: that a real epoch rotation on the dev stack leaves a paid, unissued ehash quote from the closing epoch mintable. That is the end-to-end rotation witness, and it is the right gate to run before epochs rotate automatically under vnprc/hashpool#18. ## Pre-existing failure, filed separately `cargo clippy -p mint --all-targets` fails with six `absurd_extreme_comparisons` errors in `roles/mint/tests/sv2_connection_integration.rs`. That file is byte-identical on master, on this branch, and at `922637e9`, and the integrator reproduced the failure on this tree — it is not introduced by this PR and does not block it. Filed as vnprc/hashpool#27, which also records the deeper problem: the flagged assertions compare `u8` constants against that type's own bounds, so they cannot fail on any input and verify nothing. Refs vnprc/hashpool#15. Refs vnprc/hashpool#25.
Brings the cdk forge re-pin, .cargo/config.toml fetch-transport fix,
justfile pin, and docs/AGENTS.md follow-up from agent/nutxx-pubkey-lookup.
Resolved all five cdk pin sites (protocols/Cargo.toml, roles/Cargo.toml,
devenv.nix, justfile, and the regenerated roles/Cargo.lock) to this
branch's own revision, ed7657c3, at the forge URL -- the incoming merge
carried e5a85f8 in those same five places and that is the wrong revision
for this branch. roles/Cargo.lock's cdk entries, previously stale at
e5a85f8 while Cargo.toml pinned the then-unpublished ed7657c3, now
resolve correctly to ed7657c3 at the forge URL for the first time.
Author
Collaborator

End-to-end witness run on a live dev stack. The retire fix is confirmed working in the product. Epoch mechanics behaved correctly throughout. One blocker was found, and it is wallet-side, not here: vnprc/hashpool#29.

Integration of vnprc/hashpool#21, #23 and this branch, cdk pinned at ed7657c3, fresh regtest chain.

Epoch mechanics: working

Genesis opened at the live chain height, with its own keyset and the ehash route registered:

genesis epoch opened unit=hash_<pool>_17 height=17
Epoch admin listening on 127.0.0.1:3339
Creating routes for 1 custom payment methods: ["ehash"]

Mining then worked end to end — miner found shares, the proxy forwarded them upstream as valid, the mint created quotes against the epoch unit, and the sweeper minted ehash against it:

Minted 130 hash_<pool>_17 from 22 quote(s)
unit hash_<pool>_17 balance after sweep: 130
Minted 564 hash_<pool>_17 from 40 quote(s)
...
unit hash_<pool>_17 balance after sweep: 2752

Two manual rotations then produced epochs 122 and 127, each recorded in epochs.json with source: manual and its own keyset, and the mint logged Starting payment wait task for a runtime-registered processor — the cdk runtime registration path working, not just its unit tests. All three epoch keysets remained served and active afterwards.

The retire fix, proven

The second rotation retired epoch 122 while it still held 5 unissued quotes — precisely the stranding scenario that no earlier run had reproduced, and the one this PR's deregister-to-retire change exists to prevent.

Across every sweeper pass after that retirement, the count of UnsupportedUnit errors for unit 122 is zero. The only error reported for it is Quote already in use by another operation, which is a wallet-side wedge belonging to vnprc/hashpool#29.

That is the discriminating observation. Had retirement still called deregister_payment_processor, the mint would have dropped the processor map entry and those five quotes would have drawn UnsupportedUnit forever, exactly as the mutation test in cdk demonstrates. The mint continues to resolve the retired unit's processor, so the value is not stranded mint-side.

Stated precisely, so the claim is not read as broader than it is: what is proven is that retirement no longer makes the mint reject outstanding quotes in a closed epoch. What is not yet shown in the product is one of those quotes completing a mint, because vnprc/hashpool#29 prevents minting in any unit after the first epoch for an unrelated wallet-side reason. That last step needs #29 fixed, and it is worth re-running then.

Nit confirmed in passing

The epoch unit names carry the generator-point placeholder pool pubkey (0279be667e...f81798) because the dev config ships it as a valid-looking value. One of this PR's recorded open nits was to refuse that at startup; seeing it baked into real unit names and keyset identities makes the case for doing so more concrete.

End-to-end witness run on a live dev stack. **The retire fix is confirmed working in the product.** Epoch mechanics behaved correctly throughout. One blocker was found, and it is wallet-side, not here: vnprc/hashpool#29. Integration of vnprc/hashpool#21, #23 and this branch, cdk pinned at `ed7657c3`, fresh regtest chain. ## Epoch mechanics: working Genesis opened at the live chain height, with its own keyset and the `ehash` route registered: ``` genesis epoch opened unit=hash_<pool>_17 height=17 Epoch admin listening on 127.0.0.1:3339 Creating routes for 1 custom payment methods: ["ehash"] ``` Mining then worked end to end — miner found shares, the proxy forwarded them upstream as valid, the mint created quotes against the epoch unit, and the sweeper minted ehash against it: ``` Minted 130 hash_<pool>_17 from 22 quote(s) unit hash_<pool>_17 balance after sweep: 130 Minted 564 hash_<pool>_17 from 40 quote(s) ... unit hash_<pool>_17 balance after sweep: 2752 ``` Two manual rotations then produced epochs 122 and 127, each recorded in `epochs.json` with `source: manual` and its own keyset, and the mint logged `Starting payment wait task for a runtime-registered processor` — the cdk runtime registration path working, not just its unit tests. All three epoch keysets remained served and active afterwards. ## The retire fix, proven The second rotation retired epoch 122 **while it still held 5 unissued quotes** — precisely the stranding scenario that no earlier run had reproduced, and the one this PR's `deregister`-to-`retire` change exists to prevent. Across every sweeper pass after that retirement, the count of `UnsupportedUnit` errors for unit 122 is **zero**. The only error reported for it is `Quote already in use by another operation`, which is a wallet-side wedge belonging to vnprc/hashpool#29. That is the discriminating observation. Had retirement still called `deregister_payment_processor`, the mint would have dropped the processor map entry and those five quotes would have drawn `UnsupportedUnit` forever, exactly as the mutation test in cdk demonstrates. The mint continues to resolve the retired unit's processor, so the value is not stranded mint-side. Stated precisely, so the claim is not read as broader than it is: what is proven is that retirement no longer makes the mint reject outstanding quotes in a closed epoch. What is **not** yet shown in the product is one of those quotes completing a mint, because vnprc/hashpool#29 prevents minting in any unit after the first epoch for an unrelated wallet-side reason. That last step needs #29 fixed, and it is worth re-running then. ## Nit confirmed in passing The epoch unit names carry the generator-point placeholder pool pubkey (`0279be667e...f81798`) because the dev config ships it as a valid-looking value. One of this PR's recorded open nits was to refuse that at startup; seeing it baked into real unit names and keyset identities makes the case for doing so more concrete.
A restart only re-registered the current epoch's quote-creation entry.
The in-memory processor map lost every closed epoch's retired-but-
present entry, so get_payment_processor (and thus checking/minting any
outstanding quote) returned UnsupportedUnit for it forever: vnprc/hashpool#30.

load_or_genesis now registers every non-dissolved epoch (current plus
any closed one still on record), then retires all but the current one,
in that order so a closed epoch is never briefly quotable. Dissolved
epochs are still never registered.

EpochStore gains non_dissolved() to drive the restore. New unit tests
pin: a closed epoch resolves after resume but can't accept new quotes,
a dissolved epoch gets no processor entry at all, and non_dissolved()
itself excludes only dissolved records.
The old comment claimed a closed epoch is never briefly quotable during
resume; the code does let it pass through quotable before being
retired. Document why that's safe instead: resume runs before the
mint's listeners bind, so nothing external can create a quote yet.
Author
Collaborator

In plain English

This makes the mint run mining epochs. Instead of one kind of ecash forever, each epoch gets its own kind, named after the pool and the block height it opened at, with its own signing keys. The mint opens a first epoch when it starts, and just rotate-epoch closes the current one and opens the next by hand. There is no automatic trigger yet — that is separate work.

The point is that a reward earned in one epoch stays identifiable as belonging to that epoch, which is what makes per-epoch accounting possible at all.

The important fix in here: closing an epoch must not destroy money

When an epoch closes, the mint should stop issuing new rewards in it while still letting miners collect rewards they already earned. Getting that distinction wrong loses real money, and it was wrong twice.

First, closing an epoch tore it down completely. Any reward already earned but not yet collected in that epoch became permanently uncollectable — earned, paid for, and impossible to claim. This came from using a full teardown where only a partial one was wanted, so the mint stopped recognising the closed epoch entirely. Fixed by adding a gentler "retire" operation that closes the epoch to new rewards but keeps it recognisable for old ones.

That fix is why the companion cdk change exists, and it is the part worth reviewing closely.

Second, restarting the mint brought the problem straight back. The "keep recognising closed epochs" state was only held in memory, so a restart forgot it and every closed epoch's outstanding rewards became uncollectable again. Deploys and crashes would have quietly destroyed money. Fixed by rebuilding that state for every epoch on startup, not just the current one.

The second fault only showed up because the mint happened to get restarted during testing. It was not on anyone's list.

Status

Verified on a running dev stack, end to end: mined shares, rotated epochs twice, restarted the mint, and confirmed that rewards earned in a closed epoch still collect afterwards. A batch of five such rewards from a closed epoch collected successfully after a restart, which is exactly the case that used to lose them.

One earlier claim in this PR has been corrected: the original testing checked that a closed epoch's keys still existed, which they did, and took that as proof the rewards were still collectable. Existing keys do not mean collection works, and collection was broken. That check was measuring the wrong thing and passing.

Merge last, after vnprc/hashpool#21 and vnprc/hashpool#23.

## In plain English This makes the mint run mining epochs. Instead of one kind of ecash forever, each epoch gets its own kind, named after the pool and the block height it opened at, with its own signing keys. The mint opens a first epoch when it starts, and `just rotate-epoch` closes the current one and opens the next by hand. There is no automatic trigger yet — that is separate work. The point is that a reward earned in one epoch stays identifiable as belonging to that epoch, which is what makes per-epoch accounting possible at all. ## The important fix in here: closing an epoch must not destroy money When an epoch closes, the mint should stop issuing new rewards in it while still letting miners collect rewards they already earned. Getting that distinction wrong loses real money, and it was wrong twice. **First, closing an epoch tore it down completely.** Any reward already earned but not yet collected in that epoch became permanently uncollectable — earned, paid for, and impossible to claim. This came from using a full teardown where only a partial one was wanted, so the mint stopped recognising the closed epoch entirely. Fixed by adding a gentler "retire" operation that closes the epoch to new rewards but keeps it recognisable for old ones. That fix is why the companion cdk change exists, and it is the part worth reviewing closely. **Second, restarting the mint brought the problem straight back.** The "keep recognising closed epochs" state was only held in memory, so a restart forgot it and every closed epoch's outstanding rewards became uncollectable again. Deploys and crashes would have quietly destroyed money. Fixed by rebuilding that state for every epoch on startup, not just the current one. The second fault only showed up because the mint happened to get restarted during testing. It was not on anyone's list. ## Status Verified on a running dev stack, end to end: mined shares, rotated epochs twice, restarted the mint, and confirmed that rewards earned in a closed epoch still collect afterwards. A batch of five such rewards from a closed epoch collected successfully after a restart, which is exactly the case that used to lose them. One earlier claim in this PR has been corrected: the original testing checked that a closed epoch's keys still existed, which they did, and took that as proof the rewards were still collectable. Existing keys do not mean collection works, and collection was broken. That check was measuring the wrong thing and passing. Merge last, after vnprc/hashpool#21 and vnprc/hashpool#23.
FINDING 1: a retire_payment_processor failure during resume used to warn and
continue, leaving an already-registered closed epoch quotable. The retire
loop is extracted into retire_all_except and now aborts on the first error,
matching the register loop's existing fail-closed behavior.

FINDING 2: resume now skips retiring the epoch immediately before a
Provisional current epoch, since EPOCH_DESIGN.md's lifecycle retires the
previous epoch only once the successor finalizes. EpochState::Provisional is
unreachable today (no reward trigger yet), so this is a guard, not a fix for
an observed failure.

FINDING 3: resume now restores only the current epoch plus closed epochs
with a quote where amount_paid > amount_issued (the mint's own
"still mintable" predicate) instead of every non-dissolved epoch
unconditionally, since each restored epoch starts a live processor task and
channel and writes mint-info twice.
The previously pinned ed7657c3 added NUT-04/NUT-05 settings with bare pushes
and rejected duplicate registration only via the in-memory processor map.
That map is empty in a fresh process while mint info is persisted, so this
mint -- which re-registers its current epoch on every resume -- appended a
duplicate copy of its settings on each restart.

Refs vnprc/hashpool#30
Resume now walks back from current through every consecutive Provisional
epoch plus the Final epoch beneath them, since a cascade of dissolves can
hand the current role to any of those (EPOCH_DESIGN.md "Dissolve"), rather
than assuming at most one such predecessor.

Register and retire are also split into two sets: register stays bounded
to current, outstanding, and the resumable chain, but retire now walks
every non-dissolved record so a quote-creation gate an earlier process
left open on a unit this run never registers still gets closed.
vnprc-agent closed this pull request 2026-10-09 06:14:04 +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!24
No description provided.