Merge pull request #628 from Routstr/fix/cashu-reservation-recovery

fix(wallet): recover stale Cashu reservations safely
This commit is contained in:
9qeklajc
2026-07-24 01:12:57 +02:00
committed by GitHub
3 changed files with 105 additions and 9 deletions
+12 -7
View File
@@ -219,8 +219,12 @@ async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int
"""Internal send function - returns amount and serialized token"""
effective_mint_url = mint_url or settings.primary_mint
wallet: Wallet = await get_wallet(effective_mint_url, unit)
proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
all_proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
proofs = [proof for proof in all_proofs if not proof.reserved]
# Fallback must compare the requested amount with liquid proofs only. Counting
# reserved proofs here can suppress fallback even though they cannot be sent.
proofs_for_mint = sum(p.amount for p in proofs)
reserved_for_mint = sum(p.amount for p in all_proofs if p.reserved)
# Fallback: proofs from untrusted source mints are swapped to primary_mint
# during receive, so the user's preferred refund_mint_url may have no proofs
@@ -232,8 +236,10 @@ async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int
)
effective_mint_url = settings.primary_mint
wallet = await get_wallet(effective_mint_url, unit)
proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
all_proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
proofs = [proof for proof in all_proofs if not proof.reserved]
proofs_for_mint = sum(p.amount for p in proofs)
reserved_for_mint = sum(p.amount for p in all_proofs if p.reserved)
all_mint_urls = list({k.mint_url for k in wallet.keysets.values()})
proof_summary = {
@@ -247,7 +253,8 @@ async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int
raw_proofs_by_keyset[p.id] = raw_proofs_by_keyset.get(p.id, 0) + p.amount
logger.info(
f"send: proof inventory | mint={effective_mint_url} unit={unit} amount={amount} "
f"primary_mint={settings.primary_mint} proofs_for_mint={proofs_for_mint} "
f"primary_mint={settings.primary_mint} liquid_proofs_for_mint={proofs_for_mint} "
f"reserved_proofs_for_mint={reserved_for_mint} "
f"all_mints={all_mint_urls} by_keyset={proof_summary} "
f"raw_proofs_by_keyset_id={raw_proofs_by_keyset} "
f"total_wallet_proofs={sum(p.amount for p in wallet.proofs)}"
@@ -848,7 +855,7 @@ async def fetch_all_balances(
"unit": unit,
"wallet_balance": proofs_balance,
"user_balance": user_balance,
"owner_balance": proofs_balance - user_balance if proofs_balance != 0 else 0,
"owner_balance": proofs_balance - user_balance,
}
return result
except Exception as e:
@@ -904,9 +911,7 @@ async def fetch_all_balances(
total_wallet_balance_sats += proofs_balance_sats
total_user_balance_sats += user_balance_sats
owner_balance = 0
if total_wallet_balance_sats != 0:
owner_balance = total_wallet_balance_sats - total_user_balance_sats
owner_balance = total_wallet_balance_sats - total_user_balance_sats
return (
balance_details,
+29 -2
View File
@@ -11,7 +11,9 @@ async def _fake_session(): # type: ignore[no-untyped-def]
yield MagicMock()
def _patches(proof_amount: int = 1000): # type: ignore[no-untyped-def]
def _patches( # type: ignore[no-untyped-def]
proof_amount: int = 1000, user_balance_msats: int = 0
):
proof = MagicMock(amount=proof_amount)
return [
patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())),
@@ -25,7 +27,7 @@ def _patches(proof_amount: int = 1000): # type: ignore[no-untyped-def]
),
patch(
"routstr.wallet.db.balances_for_mint_and_unit",
AsyncMock(return_value=0),
AsyncMock(return_value=user_balance_msats),
),
patch("routstr.wallet.db.create_session", _fake_session),
]
@@ -52,6 +54,31 @@ async def test_fetch_all_balances_falls_back_to_primary_mint() -> None:
assert total_wallet == 1000
@pytest.mark.asyncio
async def test_fetch_all_balances_reports_liability_when_wallet_is_empty() -> None:
"""An empty wallet must not hide outstanding user liabilities."""
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", []), patch.object(
settings, "primary_mint", "http://primary:3338"
):
for p in _patches(proof_amount=0, user_balance_msats=5000):
p.start()
try:
details, total_wallet, total_user, owner = await fetch_all_balances(
units=["sat"]
)
finally:
patch.stopall()
assert details[0]["wallet_balance"] == 0
assert details[0]["user_balance"] == 5
assert details[0]["owner_balance"] == -5
assert total_wallet == 0
assert total_user == 5
assert owner == -5
@pytest.mark.asyncio
async def test_fetch_all_balances_no_duplicate_primary_mint() -> None:
"""primary_mint already in cashu_mints is not inspected twice."""
+64
View File
@@ -15,6 +15,7 @@ from routstr.wallet import (
get_balance,
is_mint_connection_error,
recieve_token,
send,
send_token,
)
@@ -147,6 +148,69 @@ async def test_send_token() -> None:
assert token == "test_token"
@pytest.mark.asyncio
async def test_send_falls_back_when_preferred_mint_has_only_reserved_balance() -> None:
from routstr.core.settings import settings
preferred_wallet = Mock(keysets={}, proofs=[])
preferred_wallet.select_to_send = AsyncMock()
primary_wallet = Mock(keysets={}, proofs=[])
primary_wallet.select_to_send = AsyncMock()
primary_wallet.serialize_proofs = AsyncMock(return_value="primary-token")
primary_wallet.set_reserved_for_send = AsyncMock()
preferred_liquid = Mock(amount=500, reserved=False)
preferred_reserved = Mock(amount=600, reserved=True)
primary_liquid = Mock(amount=1000, reserved=False)
primary_wallet.select_to_send.return_value = ([primary_liquid], None)
async def get_wallet(mint_url: str, unit: str) -> Mock:
assert unit == "sat"
return primary_wallet if mint_url == "http://primary:3338" else preferred_wallet
def get_proofs(wallet: Mock, mint_url: str, unit: str) -> list[Mock]:
assert unit == "sat"
if wallet is primary_wallet:
assert mint_url == "http://primary:3338"
return [primary_liquid]
assert mint_url == "http://preferred:3338"
return [preferred_liquid, preferred_reserved]
with (
patch.object(settings, "primary_mint", "http://primary:3338"),
patch("routstr.wallet.get_wallet", side_effect=get_wallet),
patch("routstr.wallet.get_proofs_per_mint_and_unit", side_effect=get_proofs),
):
amount, token = await send(1000, "sat", "http://preferred:3338")
assert (amount, token) == (1000, "primary-token")
preferred_wallet.select_to_send.assert_not_awaited()
primary_wallet.select_to_send.assert_awaited_once_with(
[primary_liquid], 1000, set_reserved=False, include_fees=False
)
@pytest.mark.asyncio
async def test_send_primary_with_only_reserved_proofs_still_raises() -> None:
from routstr.core.settings import settings
wallet = Mock(keysets={}, proofs=[])
wallet.select_to_send = AsyncMock(side_effect=RuntimeError("balance too low"))
reserved = Mock(amount=1000, reserved=True)
with (
patch.object(settings, "primary_mint", "http://primary:3338"),
patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)),
patch("routstr.wallet.get_proofs_per_mint_and_unit", return_value=[reserved]),
pytest.raises(RuntimeError, match="balance too low"),
):
await send(1000, "sat", "http://primary:3338")
wallet.select_to_send.assert_awaited_once_with(
[], 1000, set_reserved=False, include_fees=False
)
@pytest.mark.asyncio
async def test_credit_balance() -> None:
token_data = {