From 38caf8b215feabf70cafa7f485bc498707296864 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Sat, 20 Jun 2026 13:35:25 +0200 Subject: [PATCH 1/8] feat(mint): introduce automatic keyset rotations --- .env.example | 10 ++ README.md | 20 ++++ cashu/core/settings.py | 12 ++ cashu/mint/crud.py | 17 +-- cashu/mint/keysets.py | 44 +++++++ cashu/mint/ledger.py | 2 + tests/mint/test_mint_automatic_rotations.py | 120 ++++++++++++++++++++ 7 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 tests/mint/test_mint_automatic_rotations.py diff --git a/.env.example b/.env.example index d54b97629..52e50b43c 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,9 @@ MINT_INFO_TOS_URL="https://mint.host/tos" # Increment derivation path to rotate to a new keyset # Example: m/0'/0'/0' -> m/0'/0'/1' +# NOTE: With automatic keyset rotation enabled (default), the mint manages this +# automatically in the database on startup and during execution. You do NOT +# need to manually increment MINT_DERIVATION_PATH in your .env file after a rotation. MINT_DERIVATION_PATH="m/0'/0'/0'" # Multiple derivation paths and units. Unit is parsed from the derivation path. @@ -56,6 +59,13 @@ MINT_DERIVATION_PATH="m/0'/0'/0'" # e.g. for 100 ppk: up to 10 inputs = 1 sat / 1 cent fee, for up to 20 inputs = 2 sat / 2 cent fee MINT_INPUT_FEE_PPK=100 +# Automatic keyset rotations +# When enabled (default: TRUE), active keysets are automatically rotated after the configured +# interval (default: 30 days / 2592000 seconds). The old keyset is deactivated but remains +# usable for redeeming existing proofs, while a new active keyset is generated. +# MINT_KEYSET_ROTATION_ENABLED=TRUE +# MINT_KEYSET_ROTATION_INTERVAL_SECONDS=2592000 + # To use SQLite, choose a directory to store the database MINT_DATABASE=data/mint # To use PostgreSQL, set the connection string diff --git a/README.md b/README.md index 54f32276a..ac46d58fd 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,26 @@ poetry run mint For testing, you can use Nutshell without a Lightning backend by setting `MINT_BACKEND_BOLT11_SAT=FakeWallet` in the `.env` file. +### Automatic Keyset Rotations + +Nutshell supports automatic keyset rotations to ensure active keysets are regularly rotated. This behavior is **enabled by default** with a default rotation interval of **30 days**. + +When a keyset rotation occurs: +1. A new keyset is activated (by automatically incrementing the derivation path counter, e.g., `m/0'/0'/0'` -> `m/0'/0'/1'`). +2. The old keyset is set to inactive but remains usable for redeeming existing ecash proofs. +3. The mint automatically recovers the latest active keyset from the database on subsequent restarts. You do **not** need to manually update `MINT_DERIVATION_PATH` in your `.env` file. + +#### Configuration +You can customize or disable automatic keyset rotations in your `.env`: + +```bash +# Enable or disable automatic rotations (default: TRUE) +MINT_KEYSET_ROTATION_ENABLED=TRUE + +# Set the rotation interval in seconds (default: 2592000 for 30 days) +MINT_KEYSET_ROTATION_INTERVAL_SECONDS=2592000 +``` + ### NUT-19 Caching with Redis To cache HTTP responses ([NUT-19](https://github.com/cashubtc/nuts/blob/main/19.md)), you can either install Redis manually or use the docker compose file in `docker/redis/docker-compose.yaml` to start Redis in a container. diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 5723fe519..b4f88762c 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -83,6 +83,18 @@ class MintSettings(CashuSettings): description="Interval (in seconds) for running regular tasks like the invoice checker.", ) + mint_keyset_rotation_enabled: bool = Field( + default=True, + title="Keyset rotation enabled", + description="Whether to automatically rotate keysets when they exceed the interval.", + ) + mint_keyset_rotation_interval_seconds: int = Field( + default=2592000, + gt=0, + title="Keyset rotation interval", + description="The interval in seconds after which active keysets are automatically rotated.", + ) + mint_retry_exponential_backoff_base_delay: int = Field(default=1) mint_retry_exponential_backoff_max_delay: int = Field(default=10) diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 2421789ea..ab3ea36f3 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -874,6 +874,13 @@ async def store_keyset( keyset: MintKeyset, conn: Optional[Connection] = None, ) -> None: + if not keyset.valid_from: + keyset.valid_from = db.timestamp_now_str() + if not keyset.valid_to: + keyset.valid_to = db.timestamp_now_str() + if not keyset.first_seen: + keyset.first_seen = db.timestamp_now_str() + await (conn or db).execute( f""" INSERT INTO {db.table_with_schema("keysets")} @@ -886,13 +893,9 @@ async def store_keyset( "encrypted_seed": keyset.encrypted_seed, "seed_encryption_method": keyset.seed_encryption_method, "derivation_path": keyset.derivation_path, - "valid_from": db.to_timestamp( - keyset.valid_from or db.timestamp_now_str() - ), - "valid_to": db.to_timestamp(keyset.valid_to or db.timestamp_now_str()), - "first_seen": db.to_timestamp( - keyset.first_seen or db.timestamp_now_str() - ), + "valid_from": db.to_timestamp(keyset.valid_from), + "valid_to": db.to_timestamp(keyset.valid_to), + "first_seen": db.to_timestamp(keyset.first_seen), "active": True, "version": keyset.version, "unit": keyset.unit.name, diff --git a/cashu/mint/keysets.py b/cashu/mint/keysets.py index 0d9bf5fa6..9da2837e0 100644 --- a/cashu/mint/keysets.py +++ b/cashu/mint/keysets.py @@ -1,4 +1,6 @@ import base64 +import datetime +import time from typing import Dict, List, Optional from loguru import logger @@ -245,6 +247,48 @@ async def inactivate_base64_keysets(self) -> None: self.keysets[keyset.id] = keyset await self.crud.update_keyset(keyset=keyset, db=self.db) + def should_rotate_keyset(self, keyset: MintKeyset) -> bool: + if not keyset.active or not keyset.valid_from: + return False + try: + if isinstance(keyset.valid_from, datetime.datetime): + valid_from_ts = keyset.valid_from.timestamp() + else: + valid_from_ts = float(keyset.valid_from) + except (ValueError, TypeError): + try: + valid_from_ts = datetime.datetime.strptime( + keyset.valid_from, "%Y-%m-%d %H:%M:%S" + ).timestamp() + except Exception: + logger.warning(f"Could not parse valid_from: {keyset.valid_from}. Forcing rotation.") + return True + + return (time.time() - valid_from_ts) >= settings.mint_keyset_rotation_interval_seconds + + async def rotate_keysets_if_needed(self) -> None: + if not settings.mint_keyset_rotation_enabled: + return + + active_keysets = [k for k in self.keysets.values() if k.active] + for keyset in active_keysets: + if self.should_rotate_keyset(keyset): + logger.warning( + f"Active keyset {keyset.id} for unit {keyset.unit.name} is older than " + f"the configured rotation interval ({settings.mint_keyset_rotation_interval_seconds}s). " + f"Rotating now." + ) + try: + new_keyset = await self.rotate_next_keyset( + unit=keyset.unit, + max_order=len(keyset.amounts), + input_fee_ppk=keyset.input_fee_ppk, + final_expiry=keyset.final_expiry, + ) + logger.info(f"Successfully rotated keyset {keyset.id} -> {new_keyset.id} for unit {keyset.unit.name}") + except Exception as e: + logger.error(f"Failed to automatically rotate keyset {keyset.id}: {e}") + def get_keyset(self, keyset_id: Optional[str] = None) -> Dict[int, str]: """Returns a dictionary of hex public keys of a specific keyset for each supported amount""" if keyset_id and keyset_id not in self.keysets: diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 8c0c21bc1..338b3b7ae 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -153,6 +153,7 @@ async def startup_ledger(self) -> None: async def _startup_keysets(self) -> None: await self.init_keysets() + await self.rotate_keysets_if_needed() for derivation_path in settings.mint_derivation_path_list: derivation_path = self.maybe_update_derivation_path(derivation_path) await self.activate_keyset(derivation_path=derivation_path) @@ -166,6 +167,7 @@ async def _run_regular_tasks(self) -> None: while True: try: await self._check_pending_proofs_and_melt_quotes() + await self.rotate_keysets_if_needed() await asyncio.sleep(settings.mint_regular_tasks_interval_seconds) except Exception as e: logger.error(f"Ledger regular task failed: {e}") diff --git a/tests/mint/test_mint_automatic_rotations.py b/tests/mint/test_mint_automatic_rotations.py new file mode 100644 index 000000000..984bb6a02 --- /dev/null +++ b/tests/mint/test_mint_automatic_rotations.py @@ -0,0 +1,120 @@ +import asyncio +import datetime + +import pytest + +from cashu.core.settings import settings +from cashu.mint.ledger import Ledger + + +@pytest.mark.asyncio +async def test_should_rotate_keyset_behavior(ledger: Ledger): + # Get any active keyset + keyset = next(k for k in ledger.keysets.values() if k.active) + + # By default, freshly created keyset should not rotate + assert not ledger.should_rotate_keyset(keyset) + + # If keyset is inactive, it should never rotate + keyset.active = False + assert not ledger.should_rotate_keyset(keyset) + keyset.active = True + + # If valid_from is mocked in the far past, it should rotate + original_valid_from = keyset.valid_from + keyset.valid_from = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=31)).strftime( + "%Y-%m-%d %H:%M:%S" + ) + assert ledger.should_rotate_keyset(keyset) + + # Restore + keyset.valid_from = original_valid_from + + +@pytest.mark.asyncio +async def test_automatic_keyset_rotation_flow(ledger: Ledger): + # Set a very short rotation interval + original_interval = settings.mint_keyset_rotation_interval_seconds + original_enabled = settings.mint_keyset_rotation_enabled + + try: + settings.mint_keyset_rotation_enabled = True + settings.mint_keyset_rotation_interval_seconds = 1 + + # Keep track of active keysets before rotation + active_keysets_before = { + k.unit: k for k in ledger.keysets.values() if k.active + } + assert len(active_keysets_before) > 0 + + # Wait to exceed the 1 second interval + await asyncio.sleep(1.5) + + # Trigger automatic rotation check + await ledger.rotate_keysets_if_needed() + + # Get active keysets after rotation + active_keysets_after = { + k.unit: k for k in ledger.keysets.values() if k.active + } + + for unit, old_keyset in active_keysets_before.items(): + new_keyset = active_keysets_after[unit] + # Verify a new keyset has been created and it differs from the old one + assert old_keyset.id != new_keyset.id + + # Verify the old keyset is now inactive in memory and DB + assert not old_keyset.active + db_old_keysets = await ledger.crud.get_keyset(db=ledger.db, id=old_keyset.id) + assert len(db_old_keysets) == 1 + assert not db_old_keysets[0].active + + # Verify new keyset is active in memory and DB + assert new_keyset.active + db_new_keysets = await ledger.crud.get_keyset(db=ledger.db, id=new_keyset.id) + assert len(db_new_keysets) == 1 + assert db_new_keysets[0].active + + # Verify key parameters are preserved + assert new_keyset.input_fee_ppk == old_keyset.input_fee_ppk + assert len(new_keyset.amounts) == len(old_keyset.amounts) + + # Verify derivation path counter has incremented + old_path = old_keyset.derivation_path.split("/") + new_path = new_keyset.derivation_path.split("/") + assert old_path[:-1] == new_path[:-1] + assert int(new_path[-1].replace("'", "")) - int(old_path[-1].replace("'", "")) == 1 + + finally: + # Restore settings + settings.mint_keyset_rotation_interval_seconds = original_interval + settings.mint_keyset_rotation_enabled = original_enabled + + +@pytest.mark.asyncio +async def test_automatic_keyset_rotation_disabled(ledger: Ledger): + # Keep track of active keyset + keyset = next(k for k in ledger.keysets.values() if k.active) + + original_interval = settings.mint_keyset_rotation_interval_seconds + original_enabled = settings.mint_keyset_rotation_enabled + + try: + settings.mint_keyset_rotation_enabled = False + settings.mint_keyset_rotation_interval_seconds = 1 + + # Wait to exceed interval + await asyncio.sleep(1.5) + + # Trigger check (should do nothing since disabled) + await ledger.rotate_keysets_if_needed() + + # Get active keyset for the same unit + active_keysets = [k for k in ledger.keysets.values() if k.active and k.unit == keyset.unit] + assert len(active_keysets) == 1 + assert active_keysets[0].id == keyset.id + assert active_keysets[0].active + + finally: + settings.mint_keyset_rotation_interval_seconds = original_interval + settings.mint_keyset_rotation_enabled = original_enabled From 0a3fffa2d15365710a0c68aebe09f7a2b416377a Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Tue, 23 Jun 2026 09:34:28 +0200 Subject: [PATCH 2/8] fix(mint): preserve keyset redemption grace period on automatic rotation --- cashu/mint/keysets.py | 40 ++++++---- tests/mint/test_mint_automatic_rotations.py | 83 +++++++++++++++++++-- 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/cashu/mint/keysets.py b/cashu/mint/keysets.py index 9da2837e0..7e4155d40 100644 --- a/cashu/mint/keysets.py +++ b/cashu/mint/keysets.py @@ -247,22 +247,27 @@ async def inactivate_base64_keysets(self) -> None: self.keysets[keyset.id] = keyset await self.crud.update_keyset(keyset=keyset, db=self.db) - def should_rotate_keyset(self, keyset: MintKeyset) -> bool: - if not keyset.active or not keyset.valid_from: - return False + def _parse_valid_from(self, keyset: MintKeyset) -> float: + if not keyset.valid_from: + raise ValueError("keyset.valid_from is None") try: if isinstance(keyset.valid_from, datetime.datetime): - valid_from_ts = keyset.valid_from.timestamp() + return keyset.valid_from.timestamp() else: - valid_from_ts = float(keyset.valid_from) + return float(keyset.valid_from) except (ValueError, TypeError): - try: - valid_from_ts = datetime.datetime.strptime( - keyset.valid_from, "%Y-%m-%d %H:%M:%S" - ).timestamp() - except Exception: - logger.warning(f"Could not parse valid_from: {keyset.valid_from}. Forcing rotation.") - return True + return datetime.datetime.strptime( + keyset.valid_from, "%Y-%m-%d %H:%M:%S" + ).timestamp() + + def should_rotate_keyset(self, keyset: MintKeyset) -> bool: + if not keyset.active or not keyset.valid_from: + return False + try: + valid_from_ts = self._parse_valid_from(keyset) + except Exception: + logger.warning(f"Could not parse valid_from: {keyset.valid_from}. Forcing rotation.") + return True return (time.time() - valid_from_ts) >= settings.mint_keyset_rotation_interval_seconds @@ -279,11 +284,20 @@ async def rotate_keysets_if_needed(self) -> None: f"Rotating now." ) try: + new_final_expiry = None + if keyset.final_expiry is not None: + try: + valid_from_ts = self._parse_valid_from(keyset) + except Exception: + valid_from_ts = time.time() + active_duration = int(time.time() - valid_from_ts) + new_final_expiry = keyset.final_expiry + active_duration + new_keyset = await self.rotate_next_keyset( unit=keyset.unit, max_order=len(keyset.amounts), input_fee_ppk=keyset.input_fee_ppk, - final_expiry=keyset.final_expiry, + final_expiry=new_final_expiry, ) logger.info(f"Successfully rotated keyset {keyset.id} -> {new_keyset.id} for unit {keyset.unit.name}") except Exception as e: diff --git a/tests/mint/test_mint_automatic_rotations.py b/tests/mint/test_mint_automatic_rotations.py index 984bb6a02..8d9b7fc79 100644 --- a/tests/mint/test_mint_automatic_rotations.py +++ b/tests/mint/test_mint_automatic_rotations.py @@ -1,5 +1,6 @@ import asyncio import datetime +import time import pytest @@ -7,6 +8,20 @@ from cashu.mint.ledger import Ledger +@pytest.fixture(autouse=True) +def disable_global_ledger_rotation(): + from cashu.mint.startup import ledger as global_ledger + + original_method = global_ledger.rotate_keysets_if_needed + + async def noop_rotate(*args, **kwargs): + pass + + global_ledger.rotate_keysets_if_needed = noop_rotate + yield + global_ledger.rotate_keysets_if_needed = original_method + + @pytest.mark.asyncio async def test_should_rotate_keyset_behavior(ledger: Ledger): # Get any active keyset @@ -22,9 +37,9 @@ async def test_should_rotate_keyset_behavior(ledger: Ledger): # If valid_from is mocked in the far past, it should rotate original_valid_from = keyset.valid_from - keyset.valid_from = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=31)).strftime( - "%Y-%m-%d %H:%M:%S" - ) + keyset.valid_from = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=31) + ).strftime("%Y-%m-%d %H:%M:%S") assert ledger.should_rotate_keyset(keyset) # Restore @@ -65,13 +80,17 @@ async def test_automatic_keyset_rotation_flow(ledger: Ledger): # Verify the old keyset is now inactive in memory and DB assert not old_keyset.active - db_old_keysets = await ledger.crud.get_keyset(db=ledger.db, id=old_keyset.id) + db_old_keysets = await ledger.crud.get_keyset( + db=ledger.db, id=old_keyset.id + ) assert len(db_old_keysets) == 1 assert not db_old_keysets[0].active # Verify new keyset is active in memory and DB assert new_keyset.active - db_new_keysets = await ledger.crud.get_keyset(db=ledger.db, id=new_keyset.id) + db_new_keysets = await ledger.crud.get_keyset( + db=ledger.db, id=new_keyset.id + ) assert len(db_new_keysets) == 1 assert db_new_keysets[0].active @@ -83,7 +102,11 @@ async def test_automatic_keyset_rotation_flow(ledger: Ledger): old_path = old_keyset.derivation_path.split("/") new_path = new_keyset.derivation_path.split("/") assert old_path[:-1] == new_path[:-1] - assert int(new_path[-1].replace("'", "")) - int(old_path[-1].replace("'", "")) == 1 + assert ( + int(new_path[-1].replace("'", "")) + - int(old_path[-1].replace("'", "")) + == 1 + ) finally: # Restore settings @@ -110,7 +133,11 @@ async def test_automatic_keyset_rotation_disabled(ledger: Ledger): await ledger.rotate_keysets_if_needed() # Get active keyset for the same unit - active_keysets = [k for k in ledger.keysets.values() if k.active and k.unit == keyset.unit] + active_keysets = [ + k + for k in ledger.keysets.values() + if k.active and k.unit == keyset.unit + ] assert len(active_keysets) == 1 assert active_keysets[0].id == keyset.id assert active_keysets[0].active @@ -118,3 +145,45 @@ async def test_automatic_keyset_rotation_disabled(ledger: Ledger): finally: settings.mint_keyset_rotation_interval_seconds = original_interval settings.mint_keyset_rotation_enabled = original_enabled + + +@pytest.mark.asyncio +async def test_automatic_keyset_rotation_preserves_grace_period(ledger: Ledger): + # Get any active keyset + keyset = next(k for k in ledger.keysets.values() if k.active) + + original_interval = settings.mint_keyset_rotation_interval_seconds + original_enabled = settings.mint_keyset_rotation_enabled + + try: + settings.mint_keyset_rotation_enabled = True + settings.mint_keyset_rotation_interval_seconds = 1 + + # Set a mock final_expiry on the old keyset + keyset.final_expiry = 2000000000 + + # Generate the timestamp 5 seconds ago using the database's format to avoid timezone shifts + past_ts = int(time.time() - 5) + keyset.valid_from = ledger.db.timestamp_from_seconds(past_ts) + + # Trigger automatic rotation check + await ledger.rotate_keysets_if_needed() + + # Retrieve the new active keyset for this unit + new_keyset = next( + k + for k in ledger.keysets.values() + if k.active and k.unit == keyset.unit + ) + + # Verify a rotation occurred + assert keyset.id != new_keyset.id + + # Expected new final_expiry should be original final_expiry (2000000000) + active_duration (approx 5) + assert new_keyset.final_expiry is not None + assert 2000000004 <= new_keyset.final_expiry <= 2000000008 + + finally: + # Restore settings + settings.mint_keyset_rotation_interval_seconds = original_interval + settings.mint_keyset_rotation_enabled = original_enabled From daef7a1bd1548cff846cf1cd971a4825f2b642fd Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Tue, 23 Jun 2026 22:19:33 +0200 Subject: [PATCH 3/8] docs(mint): document multiple formats in _parse_valid_from --- cashu/mint/keysets.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cashu/mint/keysets.py b/cashu/mint/keysets.py index 7e4155d40..9c6e09538 100644 --- a/cashu/mint/keysets.py +++ b/cashu/mint/keysets.py @@ -248,6 +248,10 @@ async def inactivate_base64_keysets(self) -> None: await self.crud.update_keyset(keyset=keyset, db=self.db) def _parse_valid_from(self, keyset: MintKeyset) -> float: + # Handles multiple types for keyset.valid_from because database drivers return + # different types (PostgreSQL returns datetime.datetime, SQLite stores/returns + # stringified timestamp integers/floats), while test mocks or JSON payloads + # may supply formatted datetime strings. if not keyset.valid_from: raise ValueError("keyset.valid_from is None") try: From d5eb51021d91a37f13288dc0043e2604f325a080 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Wed, 24 Jun 2026 11:17:14 +0200 Subject: [PATCH 4/8] fix(mint): update default keyset and check rotations on key requests --- cashu/mint/keysets.py | 10 +++++++++- cashu/mint/router.py | 2 ++ tests/mint/test_mint_automatic_rotations.py | 6 ++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cashu/mint/keysets.py b/cashu/mint/keysets.py index 9c6e09538..8aef98703 100644 --- a/cashu/mint/keysets.py +++ b/cashu/mint/keysets.py @@ -13,6 +13,9 @@ class LedgerKeysets(SupportsKeysets, SupportsSeed, SupportsDb): + keyset: MintKeyset + derivation_path: str + # ------- KEYS ------- def maybe_update_derivation_path(self, derivation_path: str) -> str: @@ -113,7 +116,12 @@ async def rotate_next_keyset( await self.crud.update_keyset(keyset=selected_keyset, db=self.db) self.keysets[selected_keyset.id] = selected_keyset - logger.debug(f"Keyset {keyset.id} was de-activated") + if self.keyset and self.keyset.id == selected_keyset.id: + self.keyset = new_keyset + self.derivation_path = new_keyset.derivation_path + logger.info(f"Updated default keyset to {new_keyset.id} with derivation path {new_keyset.derivation_path}") + + logger.debug(f"Keyset {selected_keyset.id} was de-activated") return new_keyset async def activate_keyset( diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 8e5b68caf..0582f2a56 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -75,6 +75,7 @@ async def info() -> GetInfoResponse: async def keys(): """This endpoint returns a dictionary of all supported token values of the mint and their associated public key.""" logger.trace("> GET /v1/keys") + await ledger.rotate_keysets_if_needed() keyset = ledger.keyset keyset_for_response = [] for keyset in ledger.keysets.values(): @@ -140,6 +141,7 @@ async def keyset_keys(keyset_id: str) -> KeysResponse: async def keysets() -> KeysetsResponse: """This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.""" logger.trace("> GET /v1/keysets") + await ledger.rotate_keysets_if_needed() keysets = [] for id, keyset in ledger.keysets.items(): keysets.append( diff --git a/tests/mint/test_mint_automatic_rotations.py b/tests/mint/test_mint_automatic_rotations.py index 8d9b7fc79..70183b32f 100644 --- a/tests/mint/test_mint_automatic_rotations.py +++ b/tests/mint/test_mint_automatic_rotations.py @@ -108,6 +108,12 @@ async def test_automatic_keyset_rotation_flow(ledger: Ledger): == 1 ) + # If the rotated unit matches the default keyset's unit, verify that + # the default keyset and derivation path are updated on the ledger + if unit == ledger.keyset.unit: + assert ledger.keyset.id == new_keyset.id + assert ledger.derivation_path == new_keyset.derivation_path + finally: # Restore settings settings.mint_keyset_rotation_interval_seconds = original_interval From 4c788ba5dcc79b9311e58bdccc2dfa91bc21698d Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Wed, 24 Jun 2026 11:23:57 +0200 Subject: [PATCH 5/8] test(mint): add background keyset rotation test and remove on-demand API checks --- cashu/mint/router.py | 2 - tests/mint/test_mint_automatic_rotations.py | 61 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 0582f2a56..8e5b68caf 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -75,7 +75,6 @@ async def info() -> GetInfoResponse: async def keys(): """This endpoint returns a dictionary of all supported token values of the mint and their associated public key.""" logger.trace("> GET /v1/keys") - await ledger.rotate_keysets_if_needed() keyset = ledger.keyset keyset_for_response = [] for keyset in ledger.keysets.values(): @@ -141,7 +140,6 @@ async def keyset_keys(keyset_id: str) -> KeysResponse: async def keysets() -> KeysetsResponse: """This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.""" logger.trace("> GET /v1/keysets") - await ledger.rotate_keysets_if_needed() keysets = [] for id, keyset in ledger.keysets.items(): keysets.append( diff --git a/tests/mint/test_mint_automatic_rotations.py b/tests/mint/test_mint_automatic_rotations.py index 70183b32f..7ba0cc82c 100644 --- a/tests/mint/test_mint_automatic_rotations.py +++ b/tests/mint/test_mint_automatic_rotations.py @@ -193,3 +193,64 @@ async def test_automatic_keyset_rotation_preserves_grace_period(ledger: Ledger): # Restore settings settings.mint_keyset_rotation_interval_seconds = original_interval settings.mint_keyset_rotation_enabled = original_enabled + + +@pytest.mark.asyncio +async def test_automatic_keyset_rotation_background(ledger: Ledger): + # Keep track of original settings + original_interval = settings.mint_keyset_rotation_interval_seconds + original_enabled = settings.mint_keyset_rotation_enabled + original_tasks_interval = settings.mint_regular_tasks_interval_seconds + + try: + # Set configuration so that background tasks and keyset rotations run very frequently + settings.mint_keyset_rotation_enabled = True + settings.mint_keyset_rotation_interval_seconds = 1 + settings.mint_regular_tasks_interval_seconds = 1 # Check every second + + # Cancel existing regular tasks so we can restart with the new interval + for task in ledger.regular_tasks: + task.cancel() + ledger.regular_tasks = [] + + # Start a new regular tasks loop with the updated 1-second interval + ledger.regular_tasks.append(asyncio.create_task(ledger._run_regular_tasks())) + + # Keep track of active keysets before background rotation + active_keysets_before = { + k.unit: k for k in ledger.keysets.values() if k.active + } + assert len(active_keysets_before) > 0 + + # Wait to exceed the rotation interval and allow the background task to run + # Keyset rotation interval is 1s, and task runs every 1s, so 2.5s is plenty of time + await asyncio.sleep(2.5) + + # Get active keysets after background rotation + active_keysets_after = { + k.unit: k for k in ledger.keysets.values() if k.active + } + + # Verify background rotation occurred successfully + for unit, old_keyset in active_keysets_before.items(): + new_keyset = active_keysets_after[unit] + assert old_keyset.id != new_keyset.id + assert not old_keyset.active + assert new_keyset.active + + # If the rotated unit matches the default keyset's unit, verify that + # the default keyset and derivation path are updated on the ledger + if unit == ledger.keyset.unit: + assert ledger.keyset.id == new_keyset.id + assert ledger.derivation_path == new_keyset.derivation_path + + finally: + # Restore settings and restart original tasks loop + settings.mint_keyset_rotation_interval_seconds = original_interval + settings.mint_keyset_rotation_enabled = original_enabled + settings.mint_regular_tasks_interval_seconds = original_tasks_interval + + for task in ledger.regular_tasks: + task.cancel() + ledger.regular_tasks = [] + ledger.regular_tasks.append(asyncio.create_task(ledger._run_regular_tasks())) From 52843a9601410db2264502b010bf9e0e5a511c49 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Thu, 25 Jun 2026 14:59:09 +0200 Subject: [PATCH 6/8] update default rotation interval to 90 days --- .env.example | 4 ++-- cashu/core/settings.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 52e50b43c..526e885ec 100644 --- a/.env.example +++ b/.env.example @@ -61,10 +61,10 @@ MINT_INPUT_FEE_PPK=100 # Automatic keyset rotations # When enabled (default: TRUE), active keysets are automatically rotated after the configured -# interval (default: 30 days / 2592000 seconds). The old keyset is deactivated but remains +# interval (default: 90 days / 7,776,000 seconds). The old keyset is deactivated but remains # usable for redeeming existing proofs, while a new active keyset is generated. # MINT_KEYSET_ROTATION_ENABLED=TRUE -# MINT_KEYSET_ROTATION_INTERVAL_SECONDS=2592000 +# MINT_KEYSET_ROTATION_INTERVAL_SECONDS=7776000 # To use SQLite, choose a directory to store the database MINT_DATABASE=data/mint diff --git a/cashu/core/settings.py b/cashu/core/settings.py index b4f88762c..fdf036e85 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -89,7 +89,7 @@ class MintSettings(CashuSettings): description="Whether to automatically rotate keysets when they exceed the interval.", ) mint_keyset_rotation_interval_seconds: int = Field( - default=2592000, + default=7776000, gt=0, title="Keyset rotation interval", description="The interval in seconds after which active keysets are automatically rotated.", From 7d60dda6d178d0ceddd01290b48b15c8df564592 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Fri, 26 Jun 2026 16:25:39 +0200 Subject: [PATCH 7/8] test(mint): fix flakiness and incorrect assertions in keyset rotation tests --- tests/mint/test_mint_automatic_rotations.py | 42 +++++++++++++++------ 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/tests/mint/test_mint_automatic_rotations.py b/tests/mint/test_mint_automatic_rotations.py index 7ba0cc82c..1c27235e5 100644 --- a/tests/mint/test_mint_automatic_rotations.py +++ b/tests/mint/test_mint_automatic_rotations.py @@ -36,18 +36,27 @@ async def test_should_rotate_keyset_behavior(ledger: Ledger): keyset.active = True # If valid_from is mocked in the far past, it should rotate - original_valid_from = keyset.valid_from - keyset.valid_from = ( - datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=31) - ).strftime("%Y-%m-%d %H:%M:%S") - assert ledger.should_rotate_keyset(keyset) - - # Restore - keyset.valid_from = original_valid_from + original_interval = settings.mint_keyset_rotation_interval_seconds + settings.mint_keyset_rotation_interval_seconds = 2592000 # 30 days + try: + original_valid_from = keyset.valid_from + keyset.valid_from = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=31) + ).strftime("%Y-%m-%d %H:%M:%S") + assert ledger.should_rotate_keyset(keyset) + finally: + # Restore + keyset.valid_from = original_valid_from + settings.mint_keyset_rotation_interval_seconds = original_interval @pytest.mark.asyncio async def test_automatic_keyset_rotation_flow(ledger: Ledger): + # Cancel background tasks to avoid race conditions with manual triggering + for task in ledger.regular_tasks: + task.cancel() + ledger.regular_tasks = [] + # Set a very short rotation interval original_interval = settings.mint_keyset_rotation_interval_seconds original_enabled = settings.mint_keyset_rotation_enabled @@ -122,6 +131,11 @@ async def test_automatic_keyset_rotation_flow(ledger: Ledger): @pytest.mark.asyncio async def test_automatic_keyset_rotation_disabled(ledger: Ledger): + # Cancel background tasks to avoid race conditions with manual triggering + for task in ledger.regular_tasks: + task.cancel() + ledger.regular_tasks = [] + # Keep track of active keyset keyset = next(k for k in ledger.keysets.values() if k.active) @@ -155,6 +169,11 @@ async def test_automatic_keyset_rotation_disabled(ledger: Ledger): @pytest.mark.asyncio async def test_automatic_keyset_rotation_preserves_grace_period(ledger: Ledger): + # Cancel background tasks to avoid race conditions with manual triggering + for task in ledger.regular_tasks: + task.cancel() + ledger.regular_tasks = [] + # Get any active keyset keyset = next(k for k in ledger.keysets.values() if k.active) @@ -206,14 +225,14 @@ async def test_automatic_keyset_rotation_background(ledger: Ledger): # Set configuration so that background tasks and keyset rotations run very frequently settings.mint_keyset_rotation_enabled = True settings.mint_keyset_rotation_interval_seconds = 1 - settings.mint_regular_tasks_interval_seconds = 1 # Check every second + settings.mint_regular_tasks_interval_seconds = 2 # Check every 2 seconds # Cancel existing regular tasks so we can restart with the new interval for task in ledger.regular_tasks: task.cancel() ledger.regular_tasks = [] - # Start a new regular tasks loop with the updated 1-second interval + # Start a new regular tasks loop with the updated 2-second interval ledger.regular_tasks.append(asyncio.create_task(ledger._run_regular_tasks())) # Keep track of active keysets before background rotation @@ -223,7 +242,8 @@ async def test_automatic_keyset_rotation_background(ledger: Ledger): assert len(active_keysets_before) > 0 # Wait to exceed the rotation interval and allow the background task to run - # Keyset rotation interval is 1s, and task runs every 1s, so 2.5s is plenty of time + # Keyset rotation interval is 1s, and task runs every 2s, so 2.5s is plenty of time + # for exactly one background rotation to run and complete. await asyncio.sleep(2.5) # Get active keysets after background rotation From e9d330e0c6d9b546c42a893f69fc659ba5a2d9e1 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Fri, 26 Jun 2026 16:41:15 +0200 Subject: [PATCH 8/8] docs(mint): update default keyset rotation interval to 90 days and remove redundant balance parameter --- README.md | 6 +++--- cashu/mint/crud.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ac46d58fd..5b54b2ccd 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ For testing, you can use Nutshell without a Lightning backend by setting `MINT_B ### Automatic Keyset Rotations -Nutshell supports automatic keyset rotations to ensure active keysets are regularly rotated. This behavior is **enabled by default** with a default rotation interval of **30 days**. +Nutshell supports automatic keyset rotations to ensure active keysets are regularly rotated. This behavior is **enabled by default** with a default rotation interval of **90 days**. When a keyset rotation occurs: 1. A new keyset is activated (by automatically incrementing the derivation path counter, e.g., `m/0'/0'/0'` -> `m/0'/0'/1'`). @@ -230,8 +230,8 @@ You can customize or disable automatic keyset rotations in your `.env`: # Enable or disable automatic rotations (default: TRUE) MINT_KEYSET_ROTATION_ENABLED=TRUE -# Set the rotation interval in seconds (default: 2592000 for 30 days) -MINT_KEYSET_ROTATION_INTERVAL_SECONDS=2592000 +# Set the rotation interval in seconds (default: 7776000 for 90 days) +MINT_KEYSET_ROTATION_INTERVAL_SECONDS=7776000 ``` ### NUT-19 Caching with Redis diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index ab3ea36f3..69aeb0a03 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -1035,7 +1035,6 @@ async def update_keyset( "version": keyset.version, "unit": keyset.unit.name, "input_fee_ppk": keyset.input_fee_ppk, - "balance": keyset.balance, "final_expiry": keyset.final_expiry, # NEW: Update final expiry }, )