diff --git a/sixty_nuts/wallet.py b/sixty_nuts/wallet.py index 1ed6b8b..61fe5e2 100644 --- a/sixty_nuts/wallet.py +++ b/sixty_nuts/wallet.py @@ -624,7 +624,43 @@ async def roll_over_proofs( unspent_proofs: list[Proof], deleted_event_ids: list[str], ) -> str: - return "" # TODO: Implement roll over logic for wallet cleanup + """Roll over unspent proofs after a partial spend and return new token id. + + This creates a new token event containing only the unspent proofs and marks + the old token event as superseded using the del field. Critical for proper + NIP-60 state transitions and preventing double-spending. + + Args: + spent_proofs: List of proofs that were spent (not used in rollover) + unspent_proofs: List of proofs to roll over to new event + deleted_event_ids: List of old event IDs to mark as superseded + + Returns: + New token event ID containing the rolled over proofs + + Raises: + WalletError: If no unspent proofs provided or event publishing fails + """ + if not unspent_proofs: + raise WalletError("No unspent proofs to roll over") + + if not deleted_event_ids: + raise WalletError("No deleted event IDs provided for rollover") + + try: + # Use EventManager to publish new token event with rollover + event_id = await self.event_manager.publish_token_event( + unspent_proofs, + deleted_token_ids=deleted_event_ids, + ) + + if not event_id: + raise WalletError("Failed to publish rollover token event") + + return event_id + + except Exception as e: + raise WalletError(f"Failed to roll over proofs: {e}") from e async def _consolidate_proofs( self, proofs: list[Proof], target_mint: str | None = None diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index f0c2b7e..50017c9 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -417,3 +417,101 @@ def test_raise_if_insufficient_balance_insufficient(self) -> None: with pytest.raises(WalletError, match="Insufficient balance"): wallet.raise_if_insufficient_balance(0, 1) + + +class TestWalletRolloverProofs: + """Test roll_over_proofs functionality.""" + + @pytest.mark.asyncio + async def test_roll_over_proofs_success(self) -> None: + """Test successful proof rollover.""" + wallet = Wallet( + nsec=generate_privkey(), + mint_urls=["http://test.mint"], + relay_urls=["ws://test.relay"], + ) + + spent_proofs: list[Proof] = [ + { + "id": "test1", + "amount": 10, + "secret": "secret1", + "C": "C1", + "mint": "http://test.mint", + "unit": "sat", + } + ] + + unspent_proofs: list[Proof] = [ + { + "id": "test2", + "amount": 20, + "secret": "secret2", + "C": "C2", + "mint": "http://test.mint", + "unit": "sat", + } + ] + + deleted_event_ids = ["event1", "event2"] + + with patch.object(wallet.event_manager, 'publish_token_event', new_callable=AsyncMock) as mock_publish: + mock_publish.return_value = "new_event_id" + + result = await wallet.roll_over_proofs( + spent_proofs=spent_proofs, + unspent_proofs=unspent_proofs, + deleted_event_ids=deleted_event_ids, + ) + + assert result == "new_event_id" + mock_publish.assert_called_once_with( + unspent_proofs, deleted_token_ids=deleted_event_ids + ) + + @pytest.mark.asyncio + async def test_roll_over_proofs_no_unspent(self) -> None: + """Test rollover with no unspent proofs raises error.""" + from sixty_nuts.types import WalletError + + wallet = Wallet( + nsec=generate_privkey(), + mint_urls=["http://test.mint"], + relay_urls=["ws://test.relay"], + ) + + with pytest.raises(WalletError, match="No unspent proofs to roll over"): + await wallet.roll_over_proofs( + spent_proofs=[], + unspent_proofs=[], + deleted_event_ids=["event1"], + ) + + @pytest.mark.asyncio + async def test_roll_over_proofs_no_deleted_events(self) -> None: + """Test rollover with no deleted event IDs raises error.""" + from sixty_nuts.types import WalletError + + wallet = Wallet( + nsec=generate_privkey(), + mint_urls=["http://test.mint"], + relay_urls=["ws://test.relay"], + ) + + unspent_proofs: list[Proof] = [ + { + "id": "test1", + "amount": 10, + "secret": "secret1", + "C": "C1", + "mint": "http://test.mint", + "unit": "sat", + } + ] + + with pytest.raises(WalletError, match="No deleted event IDs provided for rollover"): + await wallet.roll_over_proofs( + spent_proofs=[], + unspent_proofs=unspent_proofs, + deleted_event_ids=[], + )