mint epoch mechanics: height-named units, records, manual rotation lever #24
Loading…
Reference in a new issue
No description provided.
Delete branch "agent/epoch-mechanics"
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 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, andjust rotate-epochcloses 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 statichashunit 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.jsonbeside the mint DB) — single writer, atomic rename, the last non-dissolved record is current.Risk
pool_pubkey— both fail loud if missing. Prod config carries placeholder TODOs for the operator.127.0.0.1:3339default) — rotation is not reachable off-box.Validation
cargo check -p mintclean at the final pin;cargo test -p mint17+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).hash_<pool>_5, source=genesis, final); two same-height manual rotations produced_5_1and_5_2(the deterministic suffix rule); after three more blocks, rotation produced_8;epochs.jsonrecords all four with correct height/unit/state/source; mint restart logsresuming persisted epoch … height=8with no re-genesis;/v1/keysetslists 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).listen_host = "localhost"binds IPv6::1on this machine, so probes must uselocalhost, not127.0.0.1(the rest of the dev stack also useslocalhost, which is why this was never visible). The admin lever binds127.0.0.1explicitly.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.rsresume branch returns withoutregister_payment_processor; the cdk processor map is in-memory only, so after any restart with an existingepochs.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 withopen_epoch, treating duplicate-register as success.should-fix —
just clean cashu(the PR's prescribed clean-slate step) leavesepochs.jsonbehind: fresh DB + stale store = resumed epoch with no keyset/settings, quoting dead, manualrmto recover. Clean must removeepochs.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_epochfailure ordering: persist failure leaves memory/disk diverged andcurrentpointing 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.0typo exposes rotation;just rotate-epochhardcodes 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
e583e73cmints from recorded payments); "clean-slate viajust 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 vshash-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.
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 cashunow removesepochs.json; collision retry matches the typedUnitStringCollisionwith 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.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())atroles/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:Epochs use
PaymentMethod::Custom("ehash"), not bolt11, so a paid-but-unissued ehash quote falls through to the processor-map lookup and getsUnsupportedUnitonce 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.mdand 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_eqdedup 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.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
6c6ead1bmerges 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. Thejustfileconflict was two independently added recipe blocks —rotate-epochfrom this branch,generate-to/minefrom the base — and both were kept. No conflict touchedroles/mint/, confirmed by diffing the staged tree for that path.311953f1re-pins cdk frome5a85f8toed7657c32c2f9cda8af33589ab48be58831b7ac7, the rebuilt payment-processor branch.8d26afacswitches epoch retirement fromderegister_payment_processortoretire_payment_processoratroles/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 in922637e9is preserved exactly.A repo-wide audit found exactly one call site for
deregister_payment_processorbefore the change and none after, so no other path can still strand quotes.Verified by the integrator, independently of the worker
epoch/mod.rs:223;deregister_payment_processorappears nowhere inroles/.3dea7086orcba8469bremains in the tree.time0.3.41,serde_with3.17.0,darling0.21.3,simple_asn10.6.3.register_payment_processorand the typedError::UnitStringCollisionmatch are both unaffected — in particular the typed match landed in922637e9has 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.retire_payment_processorto drop the processor map entry makesretire_lets_paid_custom_method_quote_be_mintedfail withUnsupportedUnit, 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
ed7657c3exists only in a local worktree. The agent that built it has no write access togithub.com/vnprc/cdk, so the owner must push it. Until then:Cargo.tomlanddevenv.nixnameed7657c3, which is the correct final state.roles/Cargo.lockstill resolves its 16 cdk entries ate5a85f8, because re-resolving against the unpublished rev fails withrevspec 'ed7657c3' not found. The lock was deliberately not hand-edited to paper over this.cargo checkon 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.rshas no unit tests of its own and there is no harness lighter than the full stack for drivingEpochManageragainst 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-targetsfails with sixabsurd_extreme_comparisonserrors inroles/mint/tests/sv2_connection_integration.rs. That file is byte-identical on master, on this branch, and at922637e9, 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 compareu8constants against that type's own bounds, so they cannot fail on any input and verify nothing.Refs vnprc/hashpool#15. Refs vnprc/hashpool#25.
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
ehashroute registered: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:
Two manual rotations then produced epochs 122 and 127, each recorded in
epochs.jsonwithsource: manualand its own keyset, and the mint loggedStarting 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-retirechange exists to prevent.Across every sweeper pass after that retirement, the count of
UnsupportedUniterrors for unit 122 is zero. The only error reported for it isQuote 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 drawnUnsupportedUnitforever, 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.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-epochcloses 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.
Pull request closed