Compare commits

...

4 Commits

Author SHA1 Message Date
Shroominic
35295dde9a fix typing 2026-01-23 13:34:13 +08:00
Shroominic
fc359ad493 add test 2026-01-23 13:31:04 +08:00
Shroominic
aa95737ba2 fix tests 2026-01-23 13:31:00 +08:00
Shroominic
b5fd542016 Enhance token handling and migration logic in wallet.py
- Added `force_primary` parameter to `recieve_token` to control mint selection.
- Refactored `swap_to_primary_mint` to `_swap_to_primary_internal` for better clarity and reusability.
- Introduced `migrate_balance_to_primary` function to handle balance migration to the primary mint.
- Updated `credit_balance` to check and migrate balances when necessary, ensuring proper mint handling.
- Improved logging for migration processes and error handling.
2026-01-21 12:54:10 +08:00
3 changed files with 302 additions and 29 deletions

View File

@@ -20,7 +20,7 @@ async def get_balance(unit: str) -> int:
async def recieve_token(
token: str,
token: str, force_primary: bool = False
) -> tuple[int, str, str]: # amount, unit, mint_url
token_obj = deserialize_token_from_string(token)
if len(token_obj.keysets) > 1:
@@ -29,7 +29,7 @@ async def recieve_token(
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 settings.cashu_mints:
if force_primary or token_obj.mint not in settings.cashu_mints:
return await swap_to_primary_mint(token_obj, wallet)
wallet.verify_proofs_dleq(token_obj.proofs)
@@ -58,26 +58,15 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str
return token
async def swap_to_primary_mint(
token_obj: Token, token_wallet: Wallet
async def _swap_to_primary_internal(
proofs: list[Proof], amount: int, unit: str, token_wallet: Wallet
) -> tuple[int, str, str]:
logger.info(
"swap_to_primary_mint",
extra={
"mint": token_obj.mint,
"amount": token_obj.amount,
"unit": token_obj.unit,
},
)
# Ensure amount is an integer
if not isinstance(token_obj.amount, int):
token_amount = int(token_obj.amount)
else:
token_amount = token_obj.amount
token_amount = int(amount)
if token_obj.unit == "sat":
if unit == "sat":
amount_msat = token_amount * 1000
elif token_obj.unit == "msat":
elif unit == "msat":
amount_msat = token_amount
else:
raise ValueError("Invalid unit")
@@ -93,7 +82,7 @@ async def swap_to_primary_mint(
melt_quote = await token_wallet.melt_quote(mint_quote.request)
_ = await 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,
@@ -103,6 +92,91 @@ async def swap_to_primary_mint(
return int(minted_amount), settings.primary_mint_unit, settings.primary_mint
async def swap_to_primary_mint(
token_obj: Token, token_wallet: Wallet
) -> tuple[int, str, str]:
logger.info(
"swap_to_primary_mint",
extra={
"mint": token_obj.mint,
"amount": token_obj.amount,
"unit": token_obj.unit,
},
)
return await _swap_to_primary_internal(
token_obj.proofs, token_obj.amount, token_obj.unit, token_wallet
)
async def migrate_balance_to_primary(key: db.ApiKey, session: db.AsyncSession) -> None:
if not key.refund_mint_url or key.refund_mint_url == settings.primary_mint:
return
logger.info(
"Migrating balance to primary mint",
extra={
"key_hash": key.hashed_key[:8] + "...",
"current_mint": key.refund_mint_url,
"balance": key.balance,
},
)
if key.balance > 0:
source_mint = key.refund_mint_url
source_unit = key.refund_currency or "sat"
amount = key.balance
# Adjust unit if needed (db stores msats, wallet might use sats)
if source_unit == "sat":
amount_to_send = amount // 1000
else:
amount_to_send = amount
try:
wallet = await get_wallet(source_mint, source_unit)
proofs = get_proofs_per_mint_and_unit(wallet, source_mint, source_unit)
send_proofs, _ = await wallet.select_to_send(
proofs, amount_to_send, set_reserved=True, include_fees=False
)
minted_amount, minted_unit, _ = await _swap_to_primary_internal(
send_proofs, amount_to_send, source_unit, wallet
)
# Convert back to msats for DB update
if minted_unit == "sat":
new_balance = minted_amount * 1000
else:
new_balance = minted_amount
# Update balance with new amount (minus fees)
key.balance = new_balance
logger.info(
"Migration successful",
extra={
"old_balance": amount,
"new_balance": new_balance,
"fee_paid": amount - new_balance,
},
)
except Exception as e:
logger.error(
"Migration failed",
extra={"error": str(e), "key_hash": key.hashed_key[:8] + "..."},
)
# If migration fails, we abort the whole process (credit_balance will raise)
raise
# Update key metadata
key.refund_mint_url = None
key.refund_currency = None
session.add(key)
await session.commit()
await session.refresh(key)
async def credit_balance(
cashu_token: str, key: db.ApiKey, session: db.AsyncSession
) -> int:
@@ -112,7 +186,29 @@ async def credit_balance(
)
try:
amount, unit, mint_url = await recieve_token(cashu_token)
# Check for multi-mint situation
try:
token_obj = deserialize_token_from_string(cashu_token)
except Exception as e:
# Wrap any deserialization error as ValueError for consistent error handling
# This ensures invalid tokens result in 400 Bad Request
raise ValueError(f"Invalid token: {str(e)}")
target_mint = key.refund_mint_url or settings.primary_mint
force_primary = False
if token_obj.mint != target_mint:
if target_mint != settings.primary_mint:
await migrate_balance_to_primary(key, session)
target_mint = settings.primary_mint
# If token mint is different from (now possibly updated) target mint
if token_obj.mint != target_mint:
force_primary = True
amount, unit, mint_url = await recieve_token(
cashu_token, force_primary=force_primary
)
logger.info(
"credit_balance: Token redeemed successfully",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},

View File

@@ -200,7 +200,9 @@ class TestmintWallet:
token_base64 = base64.urlsafe_b64encode(token_json.encode()).decode()
return f"cashuA{token_base64}"
async def redeem_token(self, token: str) -> Tuple[int, str, str]:
async def redeem_token(
self, token: str, force_primary: bool = False
) -> Tuple[int, str, str]:
"""Redeem a Cashu token - compatible with wallet.recieve_token"""
if not self.wallet:
await self.init()
@@ -522,6 +524,7 @@ async def integration_app(
with (
patch("routstr.core.db.engine", integration_engine),
patch.object(_settings, "cashu_mints", [mint_url]),
patch.object(_settings, "primary_mint", mint_url),
patch("routstr.wallet.credit_balance", testmint_wallet.credit_balance),
patch("routstr.wallet.send_token", testmint_wallet.send_token),
patch("routstr.wallet.send_to_lnurl", testmint_wallet.send_to_lnurl),

View File

@@ -1,6 +1,6 @@
import base64
import json
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@@ -84,6 +84,10 @@ async def test_credit_balance() -> None:
mock_key = Mock()
mock_key.balance = 5000000
mock_key.hashed_key = "test_hash"
# Ensure key has refund_mint_url set to match token mint to avoid migration
mock_key.refund_mint_url = "http://mint:3338"
mock_key.refund_currency = "sat"
mock_session = AsyncMock()
# Mock session.refresh to update the balance (simulates DB reload)
@@ -99,13 +103,21 @@ async def test_credit_balance() -> None:
"routstr.wallet.recieve_token",
return_value=(1000, "sat", "http://mint:3338"),
):
amount = await credit_balance(token_str, mock_key, mock_session)
assert amount == 1000000 # converted to msat
assert mock_key.balance == 6000000 # Should be updated after refresh
# Verify atomic operations were used
assert mock_session.exec.called # Atomic UPDATE statement
assert mock_session.commit.called
assert mock_session.refresh.called
# We also need to patch deserialize_token_from_string since credit_balance calls it
with patch(
"routstr.wallet.deserialize_token_from_string"
) as mock_deserialize:
mock_token = Mock()
mock_token.mint = "http://mint:3338"
mock_deserialize.return_value = mock_token
amount = await credit_balance(token_str, mock_key, mock_session)
assert amount == 1000000 # converted to msat
assert mock_key.balance == 6000000 # Should be updated after refresh
# Verify atomic operations were used
assert mock_session.exec.called # Atomic UPDATE statement
assert mock_session.commit.called
assert mock_session.refresh.called
@pytest.mark.asyncio
@@ -131,3 +143,165 @@ async def test_recieve_token_untrusted_mint() -> None:
assert amount == 900
assert unit == "sat"
assert mint == "http://mint:3338"
# --- New Tests for Multi-Mint Logic ---
PRIMARY_MINT = "https://primary-mint.com"
MINT_A = "https://mint-a.com"
MINT_B = "https://mint-b.com"
@pytest.fixture
def mock_multi_mint_context() -> object:
from routstr.core.settings import settings
with (
patch.object(settings, "primary_mint", PRIMARY_MINT),
patch.object(settings, "cashu_mints", [PRIMARY_MINT, MINT_A, MINT_B]),
patch("routstr.wallet.recieve_token", new_callable=AsyncMock) as mock_recv,
patch(
"routstr.wallet.migrate_balance_to_primary", new_callable=AsyncMock
) as mock_mig,
patch("routstr.wallet.deserialize_token_from_string") as mock_deser,
):
mock_recv.return_value = (1000, "sat", PRIMARY_MINT)
yield {
"recieve_token": mock_recv,
"migrate": mock_mig,
"deserialize": mock_deser,
}
@pytest.mark.asyncio
async def test_topup_different_mint_migrates_and_forces_swap(
mock_multi_mint_context: dict[str, MagicMock],
) -> None:
"""
Case: User is on Mint A, tops up with Mint B token.
Expectation:
1. Migrate existing Mint A balance to Primary.
2. Force swap incoming Mint B token to Primary.
"""
ctx = mock_multi_mint_context
# Setup key on Mint A
key = Mock(spec=ApiKey)
key.hashed_key = "hash"
key.balance = 1000
key.refund_mint_url = MINT_A
key.refund_currency = "sat"
session = AsyncMock()
# Setup token from Mint B
token_obj = MagicMock()
token_obj.mint = MINT_B
ctx["deserialize"].return_value = token_obj
await credit_balance("cashuA_token_mint_b", key, session)
# Verification
ctx["migrate"].assert_called_once_with(key, session)
ctx["recieve_token"].assert_called_once_with(
"cashuA_token_mint_b", force_primary=True
)
@pytest.mark.asyncio
async def test_topup_same_mint_as_refund_mint(
mock_multi_mint_context: dict[str, MagicMock],
) -> None:
"""
Case: User is on Mint A, tops up with Mint A token.
Expectation: No migration, no forced swap.
"""
ctx = mock_multi_mint_context
# Setup key
key = Mock(spec=ApiKey)
key.hashed_key = "hash"
key.balance = 1000
key.refund_mint_url = MINT_A
key.refund_currency = "sat"
session = AsyncMock()
# Setup token to match Mint A
token_obj = MagicMock()
token_obj.mint = MINT_A
ctx["deserialize"].return_value = token_obj
await credit_balance("cashuA_token_mint_a", key, session)
# Verification
ctx["migrate"].assert_not_called()
ctx["recieve_token"].assert_called_once_with(
"cashuA_token_mint_a", force_primary=False
)
@pytest.mark.asyncio
async def test_topup_primary_when_on_primary(
mock_multi_mint_context: dict[str, MagicMock],
) -> None:
"""
Case: User is on Primary (or None), tops up with Primary token.
Expectation: No migration, no forced swap.
"""
ctx = mock_multi_mint_context
# Setup key on Primary
key = Mock(spec=ApiKey)
key.hashed_key = "hash"
key.balance = 1000
key.refund_mint_url = PRIMARY_MINT
key.refund_currency = "sat"
session = AsyncMock()
# Setup token from Primary
token_obj = MagicMock()
token_obj.mint = PRIMARY_MINT
ctx["deserialize"].return_value = token_obj
await credit_balance("cashuA_token_primary", key, session)
# Verification
ctx["migrate"].assert_not_called()
ctx["recieve_token"].assert_called_once_with(
"cashuA_token_primary", force_primary=False
)
@pytest.mark.asyncio
async def test_topup_mint_a_when_on_primary(
mock_multi_mint_context: dict[str, MagicMock],
) -> None:
"""
Case: User is on Primary, tops up with Mint A token.
Expectation: No migration (already on primary), but force swap incoming token.
"""
ctx = mock_multi_mint_context
# Setup key on Primary
key = Mock(spec=ApiKey)
key.hashed_key = "hash"
key.balance = 1000
key.refund_mint_url = PRIMARY_MINT
key.refund_currency = "sat"
session = AsyncMock()
# Setup token from Mint A
token_obj = MagicMock()
token_obj.mint = MINT_A
ctx["deserialize"].return_value = token_obj
await credit_balance("cashuA_token_mint_a", key, session)
# Verification
ctx["migrate"].assert_not_called()
ctx["recieve_token"].assert_called_once_with(
"cashuA_token_mint_a", force_primary=True
)