From 10a72ac1ffb386c4bafc28a235a359416b337590 Mon Sep 17 00:00:00 2001 From: Ashen <310210685+ashen0x@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:51:47 +0530 Subject: [PATCH] fix(wallet): resolve short Cashu keyset IDs before redemption --- routstr/wallet.py | 73 ++++++-- tests/integration/test_swap_fee_retry.py | 4 +- tests/unit/test_short_keyset_ids.py | 210 +++++++++++++++++++++++ tests/unit/test_wallet.py | 35 +++- 4 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 tests/unit/test_short_keyset_ids.py diff --git a/routstr/wallet.py b/routstr/wallet.py index 3ab20c78..be7cd76e 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -299,6 +299,43 @@ async def get_balance(unit: str) -> int: return wallet.available_balance.amount +async def _load_and_resolve_token_proofs( + wallet: Wallet, token_obj: Token, *, op_name: str +) -> list[Proof]: + """Load mint keysets and return the proof list used for redemption. + + ``TokenV4.proofs`` rebuilds its list on each access, so callers must reuse + this list after Cashu expands short keyset IDs in place. + """ + # Cashu's load_mint() suppresses failures, which can leave cached keysets stale. + try: + await run_mint_operation( + lambda: wallet.load_mint_keysets(), + op_name=op_name, + mint_url=token_obj.mint, + ) + except Exception as error: + if is_mint_connection_error(error) or is_mint_rate_limited(error): + raise + raise MintConnectionError("Cashu mint keysets are unavailable") from error + try: + await wallet.activate_keyset() + except Exception as error: + # Cashu raises plain Exception when no active keyset exists. + raise MintConnectionError("Cashu mint has no active keyset") from error + + proofs = token_obj.proofs + try: + # Cashu's TokenV4 receive helper uses this resolver after load_mint(). + await wallet._expand_short_keyset_ids(proofs) + except (KeyError, ValueError) as error: + raise ValueError( + "Cashu token references an unknown or ambiguous keyset" + ) from error + + return proofs + + async def _redeem_same_mint( wallet: Wallet, token_obj: Token ) -> tuple[int, str, str]: # amount, unit, mint_url @@ -311,10 +348,10 @@ async def _redeem_same_mint( drifts insolvent. """ try: - await run_mint_operation( - lambda: wallet.load_mint(keyset_id=token_obj.keysets[0]), + proofs = await _load_and_resolve_token_proofs( + wallet, + token_obj, op_name="redeem_load_mint", - mint_url=token_obj.mint, ) except Exception as error: if is_mint_connection_error(error): @@ -336,11 +373,11 @@ async def _redeem_same_mint( ) from error raise - wallet.verify_proofs_dleq(token_obj.proofs) - input_fees = wallet.get_fees_for_proofs(token_obj.proofs) + wallet.verify_proofs_dleq(proofs) + input_fees = wallet.get_fees_for_proofs(proofs) try: await run_mint_operation( - lambda: wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True), + lambda: wallet.split(proofs=proofs, amount=0, include_fees=True), op_name="redeem_split", mint_url=token_obj.mint, retry_timeouts=False, @@ -405,7 +442,6 @@ async def _recieve_token_locked( ) wallet = await get_wallet(token_obj.mint, token_obj.unit, load=False) - wallet.keyset_id = token_obj.keysets[0] if token_obj.mint not in destinations: logger.info( "Cashu cross-mint swap required", @@ -1285,6 +1321,19 @@ async def swap_to_trusted_mint( ) return await _redeem_same_mint(token_wallet, token_obj) + try: + proofs = await _load_and_resolve_token_proofs( + token_wallet, + token_obj, + op_name="swap_load_source_mint", + ) + except Exception as error: + if is_mint_connection_error(error): + raise SourceMintConnectionError( + "Issuing Cashu mint is unreachable" + ) from error + raise + primary_wallet: Wallet | None = None minted_amount = await _calculate_swap_amount( @@ -1293,7 +1342,7 @@ async def swap_to_trusted_mint( token_obj.mint, token_wallet, primary_wallet, - token_obj.proofs, + proofs, destination_candidates, ) @@ -1372,7 +1421,7 @@ async def swap_to_trusted_mint( "Issuing Cashu mint is unreachable" ) from error raise - input_fees = token_wallet.get_fees_for_proofs(token_obj.proofs) + input_fees = token_wallet.get_fees_for_proofs(proofs) total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees logger.info( "swap_to_trusted_mint: melt quote received", @@ -1426,7 +1475,7 @@ async def swap_to_trusted_mint( try: melt_response = await run_mint_operation( lambda: token_wallet.melt( - proofs=token_obj.proofs, + proofs=proofs, invoice=mint_quote.request, fee_reserve_sat=melt_quote.fee_reserve, quote_id=melt_quote.quote, @@ -1436,7 +1485,7 @@ async def swap_to_trusted_mint( retry_timeouts=False, ) await _confirm_melt_paid( - token_wallet, melt_quote.quote, token_obj.proofs, melt_response + token_wallet, melt_quote.quote, proofs, melt_response ) except Exception as e: shortfall = _melt_insufficient_shortfall(e) @@ -1449,7 +1498,7 @@ async def swap_to_trusted_mint( ) from e if is_mint_connection_error(e): await _reconcile_ambiguous_melt( - token_wallet, melt_quote.quote, token_obj.proofs + token_wallet, melt_quote.quote, proofs ) logger.info( "Source melt reconciled as paid; minting on destination", diff --git a/tests/integration/test_swap_fee_retry.py b/tests/integration/test_swap_fee_retry.py index 7caa83b9..8d4317c0 100644 --- a/tests/integration/test_swap_fee_retry.py +++ b/tests/integration/test_swap_fee_retry.py @@ -56,7 +56,9 @@ def _make_swap_mocks( mock_token.proofs = [Mock(amount=token_amount)] mock_token_wallet = Mock() - mock_token_wallet.load_mint = AsyncMock() + mock_token_wallet.load_mint_keysets = AsyncMock() + mock_token_wallet.activate_keyset = AsyncMock() + mock_token_wallet._expand_short_keyset_ids = AsyncMock() mock_token_wallet.load_proofs = AsyncMock() mock_token_wallet.get_fees_for_proofs = Mock(return_value=input_fees) diff --git a/tests/unit/test_short_keyset_ids.py b/tests/unit/test_short_keyset_ids.py new file mode 100644 index 00000000..1245c91d --- /dev/null +++ b/tests/unit/test_short_keyset_ids.py @@ -0,0 +1,210 @@ +from typing import cast +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest +from cashu.core.base import ( + MeltQuoteState, + Proof, + TokenV4, + TokenV4Proof, + TokenV4Token, +) + +from routstr.wallet import ( + SourceMintConnectionError, + Wallet, + _redeem_same_mint, + classify_redemption_error, + swap_to_trusted_mint, +) + +MINT_URL = "https://mint.example" +FULL_V2_ID = "01" + "11" * 32 +SHORT_V2_ID = FULL_V2_ID[:16] +OTHER_V2_ID = "01" + "22" * 32 +LEGACY_V1_ID = "00" + "33" * 7 + + +def _token(keyset_id: str = SHORT_V2_ID, amounts: tuple[int, ...] = (5,)) -> TokenV4: + return TokenV4( + m=MINT_URL, + u="sat", + t=[ + TokenV4Token( + i=bytes.fromhex(keyset_id), + p=[ + TokenV4Proof( + a=amount, + s=f"synthetic-proof-{index}", + c=bytes.fromhex("02" + f"{index + 1:02x}" * 32), + ) + for index, amount in enumerate(amounts) + ], + ) + ], + ) + + +def _wallet_with_keysets(*keyset_ids: str) -> Mock: + wallet = Mock( + keysets={keyset_id: Mock(id=keyset_id) for keyset_id in keyset_ids}, + load_mint_keysets=AsyncMock(), + split=AsyncMock(), + get_fees_for_proofs=Mock(return_value=0), + verify_proofs_dleq=Mock(), + ) + + async def activate_keyset() -> None: + if not wallet.keysets: + raise Exception("No active keyset") + wallet.keyset_id = next(iter(wallet.keysets)) + + async def expand_with_cashu(proofs: list) -> None: + await Wallet._expand_short_keyset_ids(wallet, proofs) + + wallet.activate_keyset = AsyncMock(side_effect=activate_keyset) + wallet._expand_short_keyset_ids = AsyncMock(side_effect=expand_with_cashu) + return wallet + + +@pytest.mark.asyncio +async def test_same_mint_redeem_reuses_the_resolved_proofs() -> None: + token = _token(amounts=(8, 9)) + wallet = _wallet_with_keysets(FULL_V2_ID) + + assert await _redeem_same_mint(wallet, token) == (17, "sat", MINT_URL) + + split_proofs = wallet.split.await_args.kwargs["proofs"] + assert [proof.id for proof in split_proofs] == [FULL_V2_ID, FULL_V2_ID] + assert wallet.keyset_id == FULL_V2_ID + assert token.proofs[0].id == SHORT_V2_ID + assert wallet.verify_proofs_dleq.call_args.args[0] is split_proofs + assert wallet.get_fees_for_proofs.call_args.args[0] is split_proofs + wallet.load_mint_keysets.assert_awaited_once_with() + wallet.activate_keyset.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_legacy_v1_id_remains_compatible() -> None: + token = _token(LEGACY_V1_ID) + wallet = _wallet_with_keysets(LEGACY_V1_ID) + + assert await _redeem_same_mint(wallet, token) == (5, "sat", MINT_URL) + + split_proofs = wallet.split.await_args.kwargs["proofs"] + assert split_proofs[0].id == LEGACY_V1_ID + wallet._expand_short_keyset_ids.assert_awaited_once_with(split_proofs) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "known_ids", + [ + (OTHER_V2_ID,), + ( + FULL_V2_ID, + SHORT_V2_ID + "ff" * 25, + ), + ], + ids=["unknown", "ambiguous"], +) +async def test_unknown_or_ambiguous_short_id_fails_before_mutation( + known_ids: tuple[str, ...], +) -> None: + wallet = _wallet_with_keysets(*known_ids) + + with pytest.raises(ValueError, match="unknown or ambiguous keyset") as caught: + await _redeem_same_mint(wallet, _token()) + + wallet.split.assert_not_awaited() + classified = classify_redemption_error(caught.value) + assert classified is not None + assert classified[1] == 400 + assert classified[3] == "cashu_token_redemption_failed" + + +@pytest.mark.asyncio +async def test_missing_mint_keysets_is_retryable_before_mutation() -> None: + wallet = _wallet_with_keysets() + + with pytest.raises(SourceMintConnectionError) as caught: + await _redeem_same_mint(wallet, _token()) + + wallet.split.assert_not_awaited() + classified = classify_redemption_error(caught.value) + assert classified is not None + assert classified[1] == 503 + assert classified[3] == "cashu_source_mint_unreachable" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "load_error", + [ + httpx.ConnectError("mint unavailable"), + Exception("Mint Error: restarting"), + ], + ids=["transport", "mint-error"], +) +async def test_cached_keysets_do_not_mask_a_refresh_failure( + load_error: Exception, +) -> None: + wallet = _wallet_with_keysets(OTHER_V2_ID) + wallet.load_mint_keysets.side_effect = load_error + + with pytest.raises(SourceMintConnectionError) as caught: + await _redeem_same_mint(wallet, _token()) + + wallet.activate_keyset.assert_not_awaited() + wallet._expand_short_keyset_ids.assert_not_awaited() + wallet.split.assert_not_awaited() + classified = classify_redemption_error(caught.value) + assert classified is not None + assert classified[1] == 503 + assert classified[3] == "cashu_source_mint_unreachable" + + +@pytest.mark.asyncio +async def test_cross_mint_swap_reuses_resolved_proofs_end_to_end() -> None: + token = _token() + source_wallet = _wallet_with_keysets(FULL_V2_ID) + source_wallet.melt_quote = AsyncMock( + return_value=Mock(quote="melt-quote", amount=5, fee_reserve=0) + ) + source_wallet.melt = AsyncMock(return_value=Mock(state=MeltQuoteState.paid)) + + destination_url = "https://trusted-mint.example" + destination_wallet = Mock( + load_proofs=AsyncMock(), + available_balance=Mock(amount=0), + mint=AsyncMock(), + ) + mint_quote = Mock(quote="mint-quote", request="lnbc-test-invoice") + calculate_amount = AsyncMock(return_value=5) + + with ( + patch("routstr.wallet.settings.primary_mint", destination_url), + patch("routstr.wallet.settings.primary_mint_unit", "sat"), + patch("routstr.wallet.settings.cashu_mints", [destination_url]), + patch( + "routstr.wallet._calculate_swap_amount", + calculate_amount, + ), + patch( + "routstr.wallet._request_mint_with_fallback", + AsyncMock(return_value=(destination_wallet, destination_url, mint_quote)), + ), + ): + assert await swap_to_trusted_mint(token, source_wallet) == ( + 5, + "sat", + destination_url, + ) + + calculate_call = calculate_amount.await_args + assert calculate_call is not None + resolved = cast(list[Proof], calculate_call.args[5]) + assert resolved[0].id == FULL_V2_ID + assert source_wallet.get_fees_for_proofs.call_args.args[0] is resolved + assert source_wallet.melt.await_args.kwargs["proofs"] is resolved diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index c8cecc55..a2347b14 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -143,7 +143,9 @@ async def test_recieve_token_valid() -> None: mock_token.proofs = [{"amount": 1000}] mock_deserialize.return_value = mock_token - mock_wallet.load_mint = AsyncMock() + mock_wallet.load_mint_keysets = AsyncMock() + mock_wallet.activate_keyset = AsyncMock() + mock_wallet._expand_short_keyset_ids = AsyncMock() mock_wallet.load_proofs = AsyncMock() with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet): amount, unit, mint = await recieve_token(token_str) @@ -194,7 +196,9 @@ async def test_recieve_token_trusted_mint_deducts_input_fee() -> None: mock_token.proofs = [{"amount": 1000}] mock_deserialize.return_value = mock_token - mock_wallet.load_mint = AsyncMock() + mock_wallet.load_mint_keysets = AsyncMock() + mock_wallet.activate_keyset = AsyncMock() + mock_wallet._expand_short_keyset_ids = AsyncMock() mock_wallet.load_proofs = AsyncMock() # Patch get_wallet directly so the module-level `_wallets` cache # (keyed by mint URL) can't hand back a wallet from another test. @@ -300,7 +304,8 @@ async def test_primary_mint_failure_does_not_try_another_mint() -> None: proofs=[Mock(amount=100)], ) source_wallet = Mock( - load_mint=AsyncMock(side_effect=httpx.ConnectError("mint unavailable")) + load_mint_keysets=AsyncMock(side_effect=httpx.ConnectError("mint unavailable")), + activate_keyset=AsyncMock(), ) get_wallet = AsyncMock(return_value=source_wallet) @@ -337,7 +342,9 @@ async def test_same_mint_split_timeout_is_non_retryable() -> None: proofs=[Mock(amount=1000)], ) wallet = Mock( - load_mint=AsyncMock(), + load_mint_keysets=AsyncMock(), + activate_keyset=AsyncMock(), + _expand_short_keyset_ids=AsyncMock(), split=AsyncMock(side_effect=httpx.ReadTimeout("response lost")), get_fees_for_proofs=Mock(return_value=0), ) @@ -364,7 +371,9 @@ async def test_same_mint_split_connect_error_remains_retryable() -> None: proofs=[Mock(amount=1000)], ) wallet = Mock( - load_mint=AsyncMock(), + load_mint_keysets=AsyncMock(), + activate_keyset=AsyncMock(), + _expand_short_keyset_ids=AsyncMock(), split=AsyncMock(side_effect=httpx.ConnectError("connect failed")), get_fees_for_proofs=Mock(return_value=0), ) @@ -731,7 +740,9 @@ async def test_swap_to_primary_mint_insufficient_for_fees() -> None: mock_token.proofs = [{"amount": 404}] mock_token_wallet = Mock() - mock_token_wallet.load_mint = AsyncMock() + mock_token_wallet.load_mint_keysets = AsyncMock() + mock_token_wallet.activate_keyset = AsyncMock() + mock_token_wallet._expand_short_keyset_ids = AsyncMock() mock_token_wallet.load_proofs = AsyncMock() mock_token_wallet.get_fees_for_proofs = Mock(return_value=0) @@ -807,7 +818,9 @@ async def test_swap_to_primary_mint_already_on_primary() -> None: mock_token.proofs = [{"amount": 1000}] mock_token_wallet = Mock() - mock_token_wallet.load_mint = AsyncMock() + mock_token_wallet.load_mint_keysets = AsyncMock() + mock_token_wallet.activate_keyset = AsyncMock() + mock_token_wallet._expand_short_keyset_ids = AsyncMock() mock_token_wallet.load_proofs = AsyncMock() mock_token_wallet.verify_proofs_dleq = Mock() # Mock a 3-sat input fee from the Cashu wallet API. @@ -862,7 +875,9 @@ def _make_swap_mocks( mock_token.proofs = [Mock(amount=token_amount)] mock_token_wallet = Mock() - mock_token_wallet.load_mint = AsyncMock() + mock_token_wallet.load_mint_keysets = AsyncMock() + mock_token_wallet.activate_keyset = AsyncMock() + mock_token_wallet._expand_short_keyset_ids = AsyncMock() mock_token_wallet.load_proofs = AsyncMock() mock_token_wallet.get_fees_for_proofs = Mock(return_value=input_fees) @@ -2430,7 +2445,9 @@ async def test_swap_falls_back_when_primary_wallet_cannot_load() -> None: proofs=[Mock(amount=1000)], ) source_wallet = Mock( - load_mint=AsyncMock(), + load_mint_keysets=AsyncMock(), + activate_keyset=AsyncMock(), + _expand_short_keyset_ids=AsyncMock(), load_proofs=AsyncMock(), get_fees_for_proofs=Mock(return_value=0), melt_quote=AsyncMock(