Skip to content
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
17 changes: 10 additions & 7 deletions cashu/mint/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand All @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions cashu/mint/keysets.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import base64
import datetime
import time
from typing import Dict, List, Optional

from loguru import logger
Expand Down Expand Up @@ -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,
Comment thread
a1denvalu3 marked this conversation as resolved.
Outdated
)
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:
Expand Down
2 changes: 2 additions & 0 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}")
Expand Down
120 changes: 120 additions & 0 deletions tests/mint/test_mint_automatic_rotations.py
Original file line number Diff line number Diff line change
@@ -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
Loading