mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
fix(wallet): resolve short Cashu keyset IDs before redemption
This commit is contained in:
+61
-12
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user