Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
d05167fa32 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 <db2002dominic@gmail.com>
2025-11-16 01:26:37 +00:00
7 changed files with 251 additions and 78 deletions

View File

@@ -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}
)

View File

@@ -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}

View File

@@ -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())

View File

@@ -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] = {}

View File

@@ -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),

View File

@@ -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}

View File

@@ -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