mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
1 Commits
rm-cdk-pyt
...
cursor/abs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d05167fa32 |
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
166
routstr/payment/temporary_balance.py
Normal file
166
routstr/payment/temporary_balance.py
Normal 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())
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user