Skip to content

feat(mint): introduce automatic keyset rotations#1058

Open
a1denvalu3 wants to merge 11 commits into
mainfrom
automatic-keyset-rotations
Open

feat(mint): introduce automatic keyset rotations#1058
a1denvalu3 wants to merge 11 commits into
mainfrom
automatic-keyset-rotations

Conversation

@a1denvalu3

Copy link
Copy Markdown
Collaborator

This PR introduces automatic keyset rotations for Nutshell to encourage/force operators to rotate keysets regularly, as operators rarely perform them manually.

What's Changed

  • Automatic Rotations Configuration: Added MINT_KEYSET_ROTATION_ENABLED (default: True) and MINT_KEYSET_ROTATION_INTERVAL_SECONDS (default: 30 days) to cashu/core/settings.py and documented them in .env.example.
  • Automatic Rotation Detection & Logic:
    • Implemented should_rotate_keyset to safely calculate the age of active keysets (handling both sqlite and postgres timestamp differences).
    • Implemented rotate_keysets_if_needed to automatically rotate active keysets for all configured units when they exceed the interval, while preserving customized fees, orders, and final expiry.
    • Enhanced store_keyset in DB CRUD to update the valid_from, valid_to, and first_seen timestamps on the in-memory keyset object on creation, ensuring memory and DB stay in perfect synchronization.
  • Lifecycle Integration:
    • Run automatic rotation checks on ledger startup (_startup_keysets).
    • Run automatic rotation checks periodically in the regular tasks background loop (_run_regular_tasks).
  • Documentation:
    • Documented automatic keyset rotations behavior and configuration in README.md.
    • Added comments explaining that derivation paths are automatically updated in the DB, meaning operators do NOT need to manually change MINT_DERIVATION_PATH in .env after a rotation.
  • Testing:
    • Added full integration test suite tests/mint/test_mint_automatic_rotations.py covering standard rotation, disabled mode, and parameter preservation.

Passed ruff check and mypy without any errors.

@codecov

codecov Bot commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.29126% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.71%. Comparing base (a935eb9) to head (9d12d2f).
⚠️ Report is 3 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
cashu/mint/keysets.py 89.24% 10 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1058      +/-   ##
==========================================
+ Coverage   74.54%   74.71%   +0.17%     
==========================================
  Files         115      115              
  Lines       13195    13237      +42     
==========================================
+ Hits         9836     9890      +54     
+ Misses       3359     3347      -12     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

Comment thread cashu/mint/keysets.py Outdated

@KvngMikey KvngMikey left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just the nit, otherwise lgtm!

Comment thread cashu/mint/ledger.py
while True:
try:
await self._check_pending_proofs_and_melt_quotes()
await self.rotate_keysets_if_needed()

@KvngMikey KvngMikey Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effective rotation time is MINT_KEYSET_ROTATION_INTERVAL_SECONDS + up to MINT_REGULAR_TASKS_INTERVAL_SECONDS, but the latter (a) isn't in .env.example, (b) defaults to 3600s, and (c) is described only as the invoice-checker interval, with no mention of rotation.

It's also overloaded, the same tick drives pending_proof checks. I suggest we either document this coupling in the rotation section of .env.example, or decoupling rotation onto its own timer so ROTATION_INTERVAL_SECONDS for example means what it says.

@callebtc

Copy link
Copy Markdown
Collaborator

Review findings:

  1. High — rotation is not atomic. rotate_next_keyset stores the new keyset and deactivates the old keyset in separate database transactions. If the second write fails or the process crashes between them, the new keyset is committed while the old one remains active in the database. The automatic wrapper then catches and suppresses the exception (lines 308–316), so this can surface only later as multiple active/inconsistent keysets. Please make the insert/deactivation one transaction, with an appropriate per-unit lock.

  2. High — concurrent instances/manual rotation can race. The automatic check has no locking or idempotency. Two mint processes, or the regular task and management RPC, can observe the same stale keyset and rotate concurrently. This can result in duplicate-key failures, divergent final_expiry/keyset IDs, or one process continuing to serve a keyset another process has deactivated.

  3. Medium — highest-counter selection is incomplete. rotate_next_keyset never updates selected_keyset_counter. If multiple keysets for one unit are active (possible with multiple derivation paths), automatic rotation may rotate a different keyset than the stale one and leave the stale keyset active. The new loop makes this latent issue relevant because it processes every active keyset (lines 290–313.

Also, rotation is coupled to the shared regular-task loop (ledger lines 161–174), so it can occur up to the regular-task interval late (default 3600 seconds), and can be delayed by pending-proof/melt processing.

The basic single-process path and the five new rotation tests pass, but I would request changes for atomicity and concurrency safety.

@callebtc callebtc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lack of atomicity and multiple mint race condition found

@github-project-automation github-project-automation Bot moved this from Backlog to In progress in nutshell Jul 10, 2026
@a1denvalu3 a1denvalu3 requested a review from callebtc July 10, 2026 13:19
@a1denvalu3 a1denvalu3 force-pushed the automatic-keyset-rotations branch from ca983e4 to 5f2ba3c Compare July 11, 2026 16:22
KvngMikey added a commit to KvngMikey/cashu.me that referenced this pull request Jul 15, 2026
Mints with automatic keyset rotation (cashubtc/nutshell#1058) can
deactivate their active keyset mid-session. Building outputs on a stale
keyset then fails at the mint. Resolve this proactively rather than
reacting to the error.

Centralize the fix in retryOnceOnSignedOutputs, the wrapper every
signature-requesting operation already flows through: add an
ensureKeysetsCurrent helper (keys-only fetchMintKeys refresh, then
active-keyset resolution and wallet construction from the freshly
updated store) and make the wrapper its sole caller. The wrapper now
hands each operation a fresh wallet + keyset id, so every signing path
(send, redeem, bolt11, bolt12, onchain, melt, sendToLock) builds
outputs on the keyset that is active after the refresh.

- fetchMintKeys (keys-only) is used instead of updateMintInfoAndKeys,
  which fetches /v1/info and can trigger a MOTD redirect mid-payment.
- bolt12 and onchain previously never refreshed at all (they omitted
  the update flag); they now refresh via the wrapper.
- Drop the redundant, un-awaited second
  handleOutputsHaveAlreadyBeenSignedError calls in the bolt12/onchain
  catch blocks; the wrapper already handles that path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants