Skip to content

feat(mint): introduce automatic keyset rotations#1058

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

feat(mint): introduce automatic keyset rotations#1058
a1denvalu3 wants to merge 6 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

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
709 2 707 89
View the top 2 failed test(s) by shortest run time
tests.mint.test_mint_automatic_rotations::test_should_rotate_keyset_behavior
Stack Traces | 0.523s run time
ledger = <cashu.mint.ledger.Ledger object at 0x7f1784f3b410>

    @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)
E       assert False
E        +  where False = should_rotate_keyset(<cashu.core.base.MintKeyset object at 0x7f17885a96d0>)
E        +    where should_rotate_keyset = <cashu.mint.ledger.Ledger object at 0x7f1784f3b410>.should_rotate_keyset

tests/mint/test_mint_automatic_rotations.py:43: AssertionError
tests.mint.test_mint_automatic_rotations::test_automatic_keyset_rotation_flow
Stack Traces | 2.21s run time
ledger = <cashu.mint.ledger.Ledger object at 0x7f1784a7b2c0>

    @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
                )
E               assert (2 - 0) == 1
E                +  where 2 = int('2')
E                +    where '2' = <built-in method replace of str object at 0x7f17848f2d30>("'", '')
E                +      where <built-in method replace of str object at 0x7f17848f2d30> = "2'".replace
E                +  and   0 = int('0')
E                +    where '0' = <built-in method replace of str object at 0x7f1784be3090>("'", '')
E                +      where <built-in method replace of str object at 0x7f1784be3090> = "0'".replace

tests/mint/test_mint_automatic_rotations.py:105: AssertionError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants