diff --git a/routstr/wallet.py b/routstr/wallet.py index d10864bf..dd92d913 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -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, diff --git a/tests/unit/test_fetch_all_balances.py b/tests/unit/test_fetch_all_balances.py index dcd99107..433e84d4 100644 --- a/tests/unit/test_fetch_all_balances.py +++ b/tests/unit/test_fetch_all_balances.py @@ -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.""" diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 3bb36a28..b42e5278 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -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 = {