From d05167fa32f0e87ae9b67d991aae4b165309f9d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Nov 2025 01:26:37 +0000 Subject: [PATCH] Refactor: Introduce temporary balance payment methods This commit refactors the payment system to use a temporary balance approach. It introduces a new module for payment methods, allowing for future expansion beyond just Cashu tokens. The existing `credit_balance` function is replaced with `credit_temporary_balance`, and the `topup` endpoint is updated to support multiple payment methods. Co-authored-by: db2002dominic --- routstr/auth.py | 9 +- routstr/balance.py | 58 +++++++-- routstr/payment/temporary_balance.py | 166 +++++++++++++++++++++++++ routstr/wallet.py | 56 --------- tests/integration/conftest.py | 21 +++- tests/integration/test_wallet_topup.py | 8 +- tests/unit/test_wallet.py | 11 +- 7 files changed, 251 insertions(+), 78 deletions(-) create mode 100644 routstr/payment/temporary_balance.py diff --git a/routstr/auth.py b/routstr/auth.py index b1e16987..e31c43be 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -15,7 +15,11 @@ from .payment.cost_caculation import ( MaxCostData, calculate_cost, ) -from .wallet import credit_balance, deserialize_token_from_string +from .payment.temporary_balance import ( + build_cashu_payment_payload, + credit_temporary_balance, +) +from .wallet import deserialize_token_from_string logger = get_logger(__name__) @@ -208,7 +212,8 @@ async def validate_bearer_key( extra={"token_preview": bearer_key[:50]}, ) try: - msats = await credit_balance(bearer_key, new_key, session) + payload = build_cashu_payment_payload(bearer_key) + msats = await credit_temporary_balance(payload, new_key, session) logger.info( "AUTH: credit_balance returned successfully", extra={"msats": msats} ) diff --git a/routstr/balance.py b/routstr/balance.py index 883c4673..8c7a575c 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -1,7 +1,7 @@ import asyncio import hashlib from time import monotonic -from typing import Annotated, NoReturn +from typing import Annotated, Any, NoReturn from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel @@ -10,7 +10,12 @@ from .auth import validate_bearer_key from .core.db import ApiKey, AsyncSession, get_session from .core.logging import get_logger from .core.settings import settings -from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token +from .payment.temporary_balance import ( + TemporaryBalancePaymentPayload, + build_cashu_payment_payload, + credit_temporary_balance, +) +from .wallet import recieve_token, send_to_lnurl, send_token router = APIRouter() balance_router = APIRouter(prefix="/v1/balance") @@ -74,7 +79,36 @@ async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict: class TopupRequest(BaseModel): - cashu_token: str + method: str | None = None + payload: dict[str, Any] | None = None + cashu_token: str | None = None + + +def _build_topup_payload( + cashu_token: str | None, topup_request: TopupRequest | None +) -> TemporaryBalancePaymentPayload: + if topup_request is None: + if cashu_token is None: + raise HTTPException(status_code=400, detail="A payment method is required.") + return build_cashu_payment_payload(cashu_token) + + method = (topup_request.method or "").strip() or None + payload_data: dict[str, Any] = dict(topup_request.payload or {}) + + if topup_request.cashu_token: + payload_data.setdefault("token", topup_request.cashu_token) + method = method or "cashu" + + if cashu_token and (method in (None, "cashu")) and "token" not in payload_data: + payload_data["token"] = cashu_token + method = method or "cashu" + + if method is None: + raise HTTPException( + status_code=400, detail="A payment method identifier is required." + ) + + return TemporaryBalancePaymentPayload(method=method, data=payload_data) @router.post("/topup") @@ -84,16 +118,14 @@ async def topup_wallet_endpoint( key: ApiKey = Depends(get_key_from_header), session: AsyncSession = Depends(get_session), ) -> dict[str, int]: - if topup_request is not None: - cashu_token = topup_request.cashu_token - if cashu_token is None: - raise HTTPException(status_code=400, detail="A cashu_token is required.") - - cashu_token = cashu_token.replace("\n", "").replace("\r", "").replace("\t", "") - if len(cashu_token) < 10 or "cashu" not in cashu_token: - raise HTTPException(status_code=400, detail="Invalid token format") try: - amount_msats = await credit_balance(cashu_token, key, session) + payment_payload = _build_topup_payload(cashu_token, topup_request) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) + try: + amount_msats = await credit_temporary_balance(payment_payload, key, session) except ValueError as e: error_msg = str(e) if "already spent" in error_msg.lower(): @@ -102,6 +134,8 @@ async def topup_wallet_endpoint( raise HTTPException(status_code=400, detail="Invalid token format") else: raise HTTPException(status_code=400, detail="Failed to redeem token") + except NotImplementedError as e: + raise HTTPException(status_code=501, detail=str(e)) except Exception: raise HTTPException(status_code=500, detail="Internal server error") return {"msats": amount_msats} diff --git a/routstr/payment/temporary_balance.py b/routstr/payment/temporary_balance.py new file mode 100644 index 00000000..ff12fd1c --- /dev/null +++ b/routstr/payment/temporary_balance.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import abc +from dataclasses import dataclass +from typing import Any + +from sqlmodel import col, update + +from ..core import get_logger +from ..core.db import ApiKey, AsyncSession +from ..wallet import recieve_token + +logger = get_logger(__name__) + + +@dataclass(slots=True) +class TemporaryBalancePaymentPayload: + method: str + data: dict[str, Any] + + def get_str(self, key: str) -> str: + value = self.data.get(key) + if not isinstance(value, str): + raise ValueError(f"{key} is required for {self.method} payments") + return value + + +class TemporaryBalancePaymentMethod(abc.ABC): + method: str + display_name: str + + def supports(self, payload: TemporaryBalancePaymentPayload) -> bool: + return payload.method == self.method + + @abc.abstractmethod + async def credit( + self, payload: TemporaryBalancePaymentPayload, key: ApiKey, session: AsyncSession + ) -> int: + ... + + +class TemporaryBalancePaymentRegistry: + def __init__(self) -> None: + self._methods: dict[str, TemporaryBalancePaymentMethod] = {} + + def register(self, method: TemporaryBalancePaymentMethod) -> None: + self._methods[method.method] = method + + def get(self, method: str) -> TemporaryBalancePaymentMethod: + if method not in self._methods: + raise ValueError(f"Unsupported payment method '{method}'") + return self._methods[method] + + def list_methods(self) -> list[str]: + return list(self._methods.keys()) + + +temporary_balance_payments = TemporaryBalancePaymentRegistry() + + +async def credit_temporary_balance( + payload: TemporaryBalancePaymentPayload, key: ApiKey, session: AsyncSession +) -> int: + method = temporary_balance_payments.get(payload.method) + return await method.credit(payload, key, session) + + +def build_cashu_payment_payload(token: str) -> TemporaryBalancePaymentPayload: + return TemporaryBalancePaymentPayload(method="cashu", data={"token": token}) + + +class CashuTokenPaymentMethod(TemporaryBalancePaymentMethod): + method = "cashu" + display_name = "Cashu eCash" + + async def credit( + self, payload: TemporaryBalancePaymentPayload, key: ApiKey, session: AsyncSession + ) -> int: + token_raw = payload.get_str("token") + token_clean = ( + token_raw.replace("\n", "").replace("\r", "").replace("\t", "").strip() + ) + if len(token_clean) < 10 or "cashu" not in token_clean: + raise ValueError("Invalid token format") + + logger.info( + "credit_balance: Starting token redemption", + extra={"token_preview": token_clean[:50]}, + ) + + try: + amount, unit, mint_url = await recieve_token(token_clean) + logger.info( + "credit_balance: Token redeemed successfully", + extra={"amount": amount, "unit": unit, "mint_url": mint_url}, + ) + + if unit == "sat": + amount = amount * 1000 + logger.info( + "credit_balance: Converted to msat", extra={"amount_msat": amount} + ) + + logger.info( + "credit_balance: Updating balance", + extra={"old_balance": key.balance, "credit_amount": amount}, + ) + + stmt = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == key.hashed_key) + .values(balance=(ApiKey.balance) + amount) + ) + await session.exec(stmt) # type: ignore[call-overload] + await session.commit() + await session.refresh(key) + + logger.info( + "credit_balance: Balance updated successfully", + extra={"new_balance": key.balance}, + ) + + logger.info( + "Cashu token successfully redeemed and stored", + extra={"amount": amount, "unit": unit, "mint_url": mint_url}, + ) + return amount + except Exception as e: + logger.error( + "credit_balance: Error during token redemption", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + raise + + +class LightningInvoicePaymentMethod(TemporaryBalancePaymentMethod): + method = "lightning" + display_name = "Bitcoin Lightning" + + async def credit( + self, payload: TemporaryBalancePaymentPayload, key: ApiKey, session: AsyncSession + ) -> int: + # Full implementation would need to: 1) generate invoices, 2) persist pending states, + # 3) watch the lightning node or service for settlement events, 4) settle credit atomically. + raise NotImplementedError( + "Lightning-based temporary balances are not available yet." + ) + + +class UsdtCustodialPaymentMethod(TemporaryBalancePaymentMethod): + method = "usdt" + display_name = "USD Tether" + + async def credit( + self, payload: TemporaryBalancePaymentPayload, key: ApiKey, session: AsyncSession + ) -> int: + # Full implementation would need to: 1) integrate with a custodian or on-chain monitor, + # 2) handle stablecoin confirmations, 3) price conversions to msats, 4) settle and refund. + raise NotImplementedError( + "USDT-based temporary balances are not available yet." + ) + + +temporary_balance_payments.register(CashuTokenPaymentMethod()) +temporary_balance_payments.register(LightningInvoicePaymentMethod()) +temporary_balance_payments.register(UsdtCustodialPaymentMethod()) diff --git a/routstr/wallet.py b/routstr/wallet.py index 34569b85..49fe1fc6 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -5,8 +5,6 @@ from typing import TypedDict from cashu.core.base import Proof, Token from cashu.wallet.helpers import deserialize_token_from_string from cashu.wallet.wallet import Wallet -from sqlmodel import col, update - from .core import db, get_logger from .core.settings import settings from .payment.lnurl import raw_send_to_lnurl @@ -103,60 +101,6 @@ async def swap_to_primary_mint( return int(minted_amount), settings.primary_mint_unit, settings.primary_mint -async def credit_balance( - cashu_token: str, key: db.ApiKey, session: db.AsyncSession -) -> int: - logger.info( - "credit_balance: Starting token redemption", - extra={"token_preview": cashu_token[:50]}, - ) - - try: - amount, unit, mint_url = await recieve_token(cashu_token) - logger.info( - "credit_balance: Token redeemed successfully", - extra={"amount": amount, "unit": unit, "mint_url": mint_url}, - ) - - if unit == "sat": - amount = amount * 1000 - logger.info( - "credit_balance: Converted to msat", extra={"amount_msat": amount} - ) - - logger.info( - "credit_balance: Updating balance", - extra={"old_balance": key.balance, "credit_amount": amount}, - ) - - # Use atomic SQL UPDATE to prevent race conditions during concurrent topups - stmt = ( - update(db.ApiKey) - .where(col(db.ApiKey.hashed_key) == key.hashed_key) - .values(balance=(db.ApiKey.balance) + amount) - ) - await session.exec(stmt) # type: ignore[call-overload] - await session.commit() - await session.refresh(key) - - logger.info( - "credit_balance: Balance updated successfully", - extra={"new_balance": key.balance}, - ) - - logger.info( - "Cashu token successfully redeemed and stored", - extra={"amount": amount, "unit": unit, "mint_url": mint_url}, - ) - return amount - except Exception as e: - logger.error( - "credit_balance: Error during token redemption", - extra={"error": str(e), "error_type": type(e).__name__}, - ) - raise - - _wallets: dict[str, Wallet] = {} diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1977101f..f7e730a0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -280,10 +280,24 @@ class TestmintWallet: return 100000 # 100k sats async def credit_balance( - self, cashu_token: str, key: ApiKey, session: AsyncSession + self, payload: Any, key: ApiKey, session: AsyncSession ) -> int: """Credit balance to API key - test implementation""" try: + from routstr.payment.temporary_balance import ( + TemporaryBalancePaymentPayload, + ) + + if isinstance(payload, TemporaryBalancePaymentPayload): + cashu_token = payload.data.get("token") + elif isinstance(payload, dict): + cashu_token = payload.get("token") + else: + cashu_token = payload + + if not isinstance(cashu_token, str): + raise ValueError("token missing from payment payload") + logger.info( f"TestmintWallet.credit_balance called with token: {cashu_token[:20]}..." ) @@ -522,7 +536,10 @@ async def integration_app( with ( patch("routstr.core.db.engine", integration_engine), patch.object(_settings, "cashu_mints", [mint_url]), - patch("routstr.wallet.credit_balance", testmint_wallet.credit_balance), + patch( + "routstr.payment.temporary_balance.credit_temporary_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), patch("routstr.wallet.recieve_token", testmint_wallet.redeem_token), diff --git a/tests/integration/test_wallet_topup.py b/tests/integration/test_wallet_topup.py index 6f4d5c29..ceb75fb9 100644 --- a/tests/integration/test_wallet_topup.py +++ b/tests/integration/test_wallet_topup.py @@ -424,9 +424,11 @@ async def test_network_failure_during_token_verification( # type: ignore[no-unt # Generate a valid token token = await testmint_wallet.mint_tokens(300) - # Mock credit_balance to simulate network failure during token verification - with patch("routstr.balance.credit_balance") as mock_credit_balance: - mock_credit_balance.side_effect = Exception("Network error: Connection timeout") + # Mock credit orchestration to simulate network failure during token verification + with patch( + "routstr.payment.temporary_balance.credit_temporary_balance" + ) as mock_credit: + mock_credit.side_effect = Exception("Network error: Connection timeout") response = await authenticated_client.post( "/v1/wallet/topup", params={"cashu_token": token} diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index f9a49434..cda8d118 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -5,7 +5,11 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from routstr.core.db import ApiKey -from routstr.wallet import credit_balance, get_balance, recieve_token, send_token +from routstr.payment.temporary_balance import ( + build_cashu_payment_payload, + credit_temporary_balance, +) +from routstr.wallet import get_balance, recieve_token, send_token @pytest.mark.asyncio @@ -96,10 +100,11 @@ async def test_credit_balance() -> None: with patch.object(settings, "cashu_mints", ["http://mint:3338"]): with patch( - "routstr.wallet.recieve_token", + "routstr.payment.temporary_balance.recieve_token", return_value=(1000, "sat", "http://mint:3338"), ): - amount = await credit_balance(token_str, mock_key, mock_session) + payload = build_cashu_payment_payload(token_str) + amount = await credit_temporary_balance(payload, 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