Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: 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=7776000

# 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 **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'`).
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: 7776000 for 90 days)
MINT_KEYSET_ROTATION_INTERVAL_SECONDS=7776000
```

### 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 @@ -86,6 +86,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=7776000,
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
18 changes: 10 additions & 8 deletions cashu/mint/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,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 @@ -899,13 +906,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 Expand Up @@ -1045,7 +1048,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
},
)
Expand Down
213 changes: 169 additions & 44 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 All @@ -11,6 +13,9 @@


class LedgerKeysets(SupportsKeysets, SupportsSeed, SupportsDb):
keyset: MintKeyset
derivation_path: str

# ------- KEYS -------

def maybe_update_derivation_path(self, derivation_path: str) -> str:
Expand Down Expand Up @@ -40,6 +45,7 @@ async def rotate_next_keyset(
max_order: Optional[int] = None,
input_fee_ppk: Optional[int] = None,
final_expiry: Optional[int] = None,
active_keyset_id: Optional[str] = None,
) -> MintKeyset:
"""
This function:
Expand All @@ -54,65 +60,115 @@ async def rotate_next_keyset(
max_order (Optional[int], optional): The number of keys to generate, which correspond to powers of 2.
input_fee_ppk (Optional[int], optional): The new keyset's fee
final_expiry (Optional[int], optional): The keyset's expiration date, after which it might be dropped from the database.
active_keyset_id (Optional[str], optional): The active keyset ID that triggered rotation check.
Returns:
MintKeyset: Resulting keyset of the rotation
"""

logger.info(f"Attempting keyset rotation for unit {str(unit)}")

# Select keyset with the greatest counter
selected_keyset = None
selected_keyset_counter = -1
for keyset in self.keysets.values():
if keyset.active and keyset.unit == unit:
keyset_derivation_path = keyset.derivation_path.split("/")
keyset_derivation_counter = int(
keyset_derivation_path[-1].replace("'", "")
async with self.db.connect(lock_table="keysets") as conn:
# Sync in-memory keysets from DB inside the lock preserving object identity
db_keysets = await self.crud.get_keyset(db=self.db, conn=conn)
for k in db_keysets:
if k.id not in self.keysets:
self.keysets[k.id] = k
else:
self.keysets[k.id].active = k.active
self.keysets[k.id].valid_from = k.valid_from
self.keysets[k.id].valid_to = k.valid_to
self.keysets[k.id].first_seen = k.first_seen
self.keysets[k.id].final_expiry = k.final_expiry

# Avoid concurrent rotations if another task/instance has already rotated
if active_keyset_id:
target_keyset = self.keysets.get(active_keyset_id)
if target_keyset and not target_keyset.active:
logger.info(
f"Keyset {active_keyset_id} was already deactivated (likely rotated by another process/task). Skipping redundant rotation."
)
active_keyset = max(
(
k
for k in self.keysets.values()
if k.active and k.unit == unit
),
key=lambda k: int(
k.derivation_path.split("/")[-1].replace("'", "")
),
default=None,
)
if active_keyset:
if self.keyset and self.keyset.id == active_keyset_id:
self.keyset = active_keyset
self.derivation_path = active_keyset.derivation_path
logger.info(
f"Updated default keyset to {active_keyset.id} with derivation path {active_keyset.derivation_path}"
)
return active_keyset

# Select keyset with the greatest counter
selected_keyset = None
selected_keyset_counter = -1
for keyset in self.keysets.values():
if keyset.active and keyset.unit == unit:
keyset_derivation_path = keyset.derivation_path.split("/")
keyset_derivation_counter = int(
keyset_derivation_path[-1].replace("'", "")
)
if keyset_derivation_counter > selected_keyset_counter:
selected_keyset = keyset
selected_keyset_counter = keyset_derivation_counter

# If no selected keyset, then there is no keyset for this unit
if not selected_keyset:
logger.error(
f"Couldn't find suitable keyset for rotation with unit {str(unit)}"
)
raise Exception(
f"Couldn't find suitable keyset for rotation with unit {str(unit)}"
)
if keyset_derivation_counter > selected_keyset_counter:
selected_keyset = keyset

# If no selected keyset, then there is no keyset for this unit
if not selected_keyset:
logger.error(
f"Couldn't find suitable keyset for rotation with unit {str(unit)}"
)
raise Exception(
f"Couldn't find suitable keyset for rotation with unit {str(unit)}"
logger.info(f"Rotating keyset {selected_keyset.id}")

# New derivation path is just old derivation path with increased counter
new_derivation_path = selected_keyset.derivation_path.split("/")
new_derivation_path[-1] = (
str(int(new_derivation_path[-1].replace("'", "")) + 1) + "'"
)

logger.info(f"Rotating keyset {selected_keyset.id}")
# keys amounts for this keyset: if amounts is None we use `self.amounts`
amounts = [2**i for i in range(max_order)] if max_order else self.amounts

# Generate the keyset
new_keyset = MintKeyset(
derivation_path="/".join(new_derivation_path),
seed=self.seed,
amounts=amounts,
input_fee_ppk=input_fee_ppk,
active=True,
final_expiry=final_expiry,
)

# New derivation path is just old derivation path with increased counter
new_derivation_path = selected_keyset.derivation_path.split("/")
new_derivation_path[-1] = (
str(int(new_derivation_path[-1].replace("'", "")) + 1) + "'"
)
logger.debug(f"New keyset was generated with Id {new_keyset.id}. Saving...")
await self.crud.store_keyset(keyset=new_keyset, db=self.db, conn=conn)

# keys amounts for this keyset: if amounts is None we use `self.amounts`
amounts = [2**i for i in range(max_order)] if max_order else self.amounts

# Generate the keyset
new_keyset = MintKeyset(
derivation_path="/".join(new_derivation_path),
seed=self.seed,
amounts=amounts,
input_fee_ppk=input_fee_ppk,
active=True,
final_expiry=final_expiry
)
logger.debug(f"De-activating keyset {selected_keyset.id}...")
selected_keyset.active = False
await self.crud.update_keyset(keyset=selected_keyset, db=self.db, conn=conn)

logger.debug(f"New keyset was generated with Id {new_keyset.id}. Saving...")
await self.crud.store_keyset(keyset=new_keyset, db=self.db)
self.keysets[new_keyset.id] = new_keyset
self.keysets[new_keyset.id] = new_keyset
self.keysets[selected_keyset.id] = selected_keyset

logger.debug(f"De-activating keyset {selected_keyset.id}...")
selected_keyset.active = False
await self.crud.update_keyset(keyset=selected_keyset, db=self.db)
self.keysets[selected_keyset.id] = selected_keyset
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 {keyset.id} was de-activated")
return new_keyset
logger.debug(f"Keyset {selected_keyset.id} was de-activated")
return new_keyset

async def activate_keyset(
self,
Expand Down Expand Up @@ -245,6 +301,75 @@ async def inactivate_base64_keysets(self) -> None:
self.keysets[keyset.id] = keyset
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:
if isinstance(keyset.valid_from, datetime.datetime):
return keyset.valid_from.timestamp()
else:
return float(keyset.valid_from)
except (ValueError, TypeError):
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

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_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=new_final_expiry,
active_keyset_id=keyset.id,
)
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 @@ -154,6 +154,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 @@ -167,6 +168,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()

@KvngMikey KvngMikey Jul 5, 2026

Copy link
Copy Markdown
Member

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.

await asyncio.sleep(settings.mint_regular_tasks_interval_seconds)
except Exception as e:
logger.error(f"Ledger regular task failed: {e}")
Expand Down
Loading
Loading