Merge pull request #663 from ashen0x/fix/cashu-short-keyset-ids

fix: handle Cashu tokens with short keyset IDs
This commit is contained in:
9qeklajc
2026-08-15 00:16:26 +02:00
committed by GitHub
4 changed files with 306 additions and 22 deletions
+61 -12
View File
@@ -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",
+3 -1
View File
@@ -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)
+216
View File
@@ -0,0 +1,216 @@
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_uses_resolved_proofs_and_active_output_keyset() -> None:
token = _token(amounts=(7,))
source_wallet = _wallet_with_keysets(FULL_V2_ID)
source_wallet.melt_quote = AsyncMock(
return_value=Mock(quote="melt-quote", amount=5, fee_reserve=2)
)
async def assert_melt_boundary(**kwargs: object) -> Mock:
assert kwargs["fee_reserve_sat"] == 2
assert source_wallet.keyset_id == FULL_V2_ID
return Mock(state=MeltQuoteState.paid)
source_wallet.melt = AsyncMock(side_effect=assert_melt_boundary)
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
+26 -9
View File
@@ -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(