mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
4 Commits
bd1edcef26
...
308-multi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35295dde9a | ||
|
|
fc359ad493 | ||
|
|
aa95737ba2 | ||
|
|
b5fd542016 |
@@ -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},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user