mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 15:26:14 +00:00
Merge pull request #628 from Routstr/fix/cashu-reservation-recovery
fix(wallet): recover stale Cashu reservations safely
This commit is contained in:
+12
-7
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user