mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
1 Commits
bd1edcef26
...
cursor/abs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5652d4e51 |
@@ -74,35 +74,55 @@ async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
cashu_token: str
|
||||
payment_data: str
|
||||
payment_method: str | None = None
|
||||
|
||||
|
||||
@router.post("/topup")
|
||||
async def topup_wallet_endpoint(
|
||||
payment_data: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
cashu_token: str | None = None,
|
||||
topup_request: TopupRequest | None = None,
|
||||
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.")
|
||||
payment_data = topup_request.payment_data
|
||||
payment_method = topup_request.payment_method or payment_method
|
||||
elif cashu_token is not None:
|
||||
payment_data = cashu_token
|
||||
payment_method = payment_method or "cashu"
|
||||
elif payment_data is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Payment data is required. Provide 'payment_data' or 'cashu_token' (deprecated).",
|
||||
)
|
||||
|
||||
if payment_data is None:
|
||||
raise HTTPException(status_code=400, detail="Payment data is required.")
|
||||
|
||||
payment_data = payment_data.replace("\n", "").replace("\r", "").replace("\t", "")
|
||||
|
||||
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)
|
||||
amount_msats = await credit_balance(payment_data, key, session, payment_method)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "already spent" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Token already spent")
|
||||
raise HTTPException(status_code=400, detail="Payment already processed")
|
||||
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
raise HTTPException(status_code=400, detail="Invalid payment data format")
|
||||
elif "could not detect" in error_msg.lower() or "unknown payment method" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Failed to redeem token")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Failed to process payment")
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(status_code=501, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Topup endpoint error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
return {"msats": amount_msats}
|
||||
|
||||
@@ -165,35 +185,38 @@ async def refund_wallet_endpoint(
|
||||
elif remaining_balance <= 0:
|
||||
raise HTTPException(status_code=400, detail="No balance to refund")
|
||||
|
||||
# Perform refund operation first, before modifying balance
|
||||
# Determine payment method from stored metadata
|
||||
# For backward compatibility, if refund_mint_url exists, assume cashu
|
||||
# TODO: Store payment_method in database for proper multi-method support
|
||||
from .payment.registry import get_payment_method, initialize_payment_methods
|
||||
|
||||
initialize_payment_methods()
|
||||
payment_method_name = "cashu"
|
||||
if key.refund_mint_url:
|
||||
payment_method_name = "cashu"
|
||||
|
||||
method = get_payment_method(payment_method_name) or get_payment_method("cashu")
|
||||
if not method:
|
||||
raise HTTPException(status_code=500, detail="Payment method not available")
|
||||
|
||||
metadata = {
|
||||
"mint_url": key.refund_mint_url,
|
||||
"currency": key.refund_currency,
|
||||
}
|
||||
|
||||
try:
|
||||
if key.refund_address:
|
||||
from .core.settings import settings as global_settings
|
||||
|
||||
await send_to_lnurl(
|
||||
remaining_balance,
|
||||
key.refund_currency or "sat",
|
||||
key.refund_mint_url or global_settings.primary_mint,
|
||||
key.refund_address,
|
||||
)
|
||||
result = {"recipient": key.refund_address}
|
||||
else:
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
token = await send_token(
|
||||
remaining_balance, refund_currency, key.refund_mint_url
|
||||
)
|
||||
result = {"token": token}
|
||||
|
||||
if key.refund_currency == "sat":
|
||||
result["sats"] = str(remaining_balance_msats // 1000)
|
||||
else:
|
||||
result["msats"] = str(remaining_balance_msats)
|
||||
|
||||
result = await method.refund_payment(
|
||||
remaining_balance_msats,
|
||||
key.refund_currency or "sat",
|
||||
key.refund_address,
|
||||
metadata,
|
||||
session,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(status_code=501, detail=str(e))
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (like 400 for balance too small)
|
||||
raise
|
||||
except Exception as e:
|
||||
# If refund fails, don't modify the database
|
||||
error_msg = str(e)
|
||||
if (
|
||||
"mint" in error_msg.lower()
|
||||
@@ -201,7 +224,7 @@ async def refund_wallet_endpoint(
|
||||
or isinstance(e, Exception)
|
||||
and "ConnectError" in str(type(e))
|
||||
):
|
||||
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
||||
raise HTTPException(status_code=503, detail="Payment service unavailable")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
|
||||
|
||||
@@ -83,6 +83,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
await _update_prices()
|
||||
await initialize_upstreams()
|
||||
|
||||
from ..payment.registry import initialize_payment_methods
|
||||
|
||||
initialize_payment_methods()
|
||||
logger.info("Payment methods initialized")
|
||||
|
||||
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||
if global_settings.models_refresh_interval_seconds > 0:
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from .payment_methods import PaymentMethod, PaymentResult
|
||||
from .registry import (
|
||||
detect_payment_method,
|
||||
get_all_payment_methods,
|
||||
get_payment_method,
|
||||
initialize_payment_methods,
|
||||
register_payment_method,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CostData",
|
||||
"CostDataError",
|
||||
"MaxCostData",
|
||||
"calculate_cost",
|
||||
"PaymentMethod",
|
||||
"PaymentResult",
|
||||
"detect_payment_method",
|
||||
"get_all_payment_methods",
|
||||
"get_payment_method",
|
||||
"initialize_payment_methods",
|
||||
"register_payment_method",
|
||||
]
|
||||
|
||||
9
routstr/payment/methods/__init__.py
Normal file
9
routstr/payment/methods/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .cashu import CashuPaymentMethod
|
||||
from .lightning import BitcoinLightningPaymentMethod
|
||||
from .usdt import USDTPaymentMethod
|
||||
|
||||
__all__ = [
|
||||
"CashuPaymentMethod",
|
||||
"BitcoinLightningPaymentMethod",
|
||||
"USDTPaymentMethod",
|
||||
]
|
||||
134
routstr/payment/methods/cashu.py
Normal file
134
routstr/payment/methods/cashu.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from sqlmodel import col, update
|
||||
|
||||
from ...core.db import ApiKey, AsyncSession
|
||||
from ...core.logging import get_logger
|
||||
from ...wallet import recieve_token, send_to_lnurl, send_token
|
||||
from ..payment_methods import PaymentMethod, PaymentResult
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CashuPaymentMethod(PaymentMethod):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "cashu"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Cashu eCash"
|
||||
|
||||
async def validate_payment_data(self, payment_data: str) -> bool:
|
||||
if not payment_data or len(payment_data) < 10:
|
||||
return False
|
||||
if "cashu" not in payment_data.lower():
|
||||
return False
|
||||
try:
|
||||
token_obj = deserialize_token_from_string(payment_data)
|
||||
return token_obj is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def process_payment(
|
||||
self, payment_data: str, key: ApiKey, session: AsyncSession
|
||||
) -> PaymentResult:
|
||||
payment_data = payment_data.replace("\n", "").replace("\r", "").replace("\t", "")
|
||||
if not await self.validate_payment_data(payment_data):
|
||||
raise ValueError("Invalid Cashu token format")
|
||||
|
||||
try:
|
||||
amount, unit, mint_url = await recieve_token(payment_data)
|
||||
logger.info(
|
||||
"Cashu payment processed",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
|
||||
if unit == "sat":
|
||||
amount_msats = amount * 1000
|
||||
else:
|
||||
amount_msats = amount
|
||||
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=(ApiKey.balance) + amount_msats)
|
||||
)
|
||||
await session.exec(stmt)
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
|
||||
metadata = {
|
||||
"mint_url": mint_url,
|
||||
"currency": unit,
|
||||
}
|
||||
|
||||
return PaymentResult(
|
||||
amount_msats=amount_msats,
|
||||
currency=unit,
|
||||
payment_method=self.name,
|
||||
metadata=metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Cashu payment processing failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
amount_msats: int,
|
||||
currency: str,
|
||||
refund_address: str | None,
|
||||
metadata: dict[str, str | None],
|
||||
session: AsyncSession,
|
||||
) -> dict[str, str]:
|
||||
mint_url = metadata.get("mint_url")
|
||||
if currency == "sat":
|
||||
remaining_balance = amount_msats // 1000
|
||||
else:
|
||||
remaining_balance = amount_msats
|
||||
|
||||
if remaining_balance <= 0:
|
||||
raise ValueError("No balance to refund")
|
||||
|
||||
try:
|
||||
if refund_address:
|
||||
from ...core.settings import settings as global_settings
|
||||
|
||||
await send_to_lnurl(
|
||||
remaining_balance,
|
||||
currency or "sat",
|
||||
mint_url or global_settings.primary_mint,
|
||||
refund_address,
|
||||
)
|
||||
result = {"recipient": refund_address}
|
||||
else:
|
||||
token = await send_token(remaining_balance, currency, mint_url)
|
||||
result = {"token": token}
|
||||
|
||||
if currency == "sat":
|
||||
result["sats"] = str(amount_msats // 1000)
|
||||
else:
|
||||
result["msats"] = str(amount_msats)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Cashu refund failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
|
||||
def extract_metadata(self, payment_data: str) -> dict[str, str | None]:
|
||||
try:
|
||||
token_obj = deserialize_token_from_string(payment_data)
|
||||
return {
|
||||
"mint_url": token_obj.mint,
|
||||
"currency": token_obj.unit,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"mint_url": None,
|
||||
"currency": None,
|
||||
}
|
||||
134
routstr/payment/methods/lightning.py
Normal file
134
routstr/payment/methods/lightning.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from ...core.db import ApiKey, AsyncSession
|
||||
from ...core.logging import get_logger
|
||||
from ..payment_methods import PaymentMethod, PaymentResult
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BitcoinLightningPaymentMethod(PaymentMethod):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "lightning"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Bitcoin Lightning Network"
|
||||
|
||||
async def validate_payment_data(self, payment_data: str) -> bool:
|
||||
if not payment_data:
|
||||
return False
|
||||
|
||||
payment_data = payment_data.strip()
|
||||
|
||||
if payment_data.startswith("lnbc") or payment_data.startswith("lntb") or payment_data.startswith("lnbcrt"):
|
||||
return True
|
||||
|
||||
if payment_data.startswith("lightning:") or payment_data.startswith("LIGHTNING:"):
|
||||
invoice = payment_data.split(":", 1)[1]
|
||||
return invoice.startswith("lnbc") or invoice.startswith("lntb") or invoice.startswith("lnbcrt")
|
||||
|
||||
return False
|
||||
|
||||
async def process_payment(
|
||||
self, payment_data: str, key: ApiKey, session: AsyncSession
|
||||
) -> PaymentResult:
|
||||
if not await self.validate_payment_data(payment_data):
|
||||
raise ValueError("Invalid Lightning invoice format")
|
||||
|
||||
payment_data = payment_data.strip()
|
||||
if payment_data.startswith("lightning:") or payment_data.startswith("LIGHTNING:"):
|
||||
payment_data = payment_data.split(":", 1)[1]
|
||||
|
||||
logger.info(
|
||||
"Processing Lightning payment",
|
||||
extra={"invoice_preview": payment_data[:50]},
|
||||
)
|
||||
|
||||
# TODO: Full implementation requires:
|
||||
# 1. Lightning node integration (e.g., LND, CLN, LDK, or similar)
|
||||
# 2. Invoice verification and payment checking
|
||||
# 3. Amount extraction from invoice (decode BOLT11 invoice)
|
||||
# 4. Payment status polling or webhook handling
|
||||
# 5. Atomic balance updates after payment confirmation
|
||||
# 6. Error handling for expired/invalid invoices
|
||||
# 7. Support for both mainnet and testnet
|
||||
#
|
||||
# Example libraries to consider:
|
||||
# - pyln-client (for Core Lightning)
|
||||
# - lndgrpc (for LND)
|
||||
# - bolt11 (for invoice decoding)
|
||||
#
|
||||
# Example flow:
|
||||
# 1. Decode invoice to get amount and expiry
|
||||
# 2. Check if invoice is already paid (query node)
|
||||
# 3. If not paid, wait for payment confirmation (poll or webhook)
|
||||
# 4. Once confirmed, credit balance atomically
|
||||
# 5. Store invoice hash in metadata to prevent double-spending
|
||||
|
||||
raise NotImplementedError(
|
||||
"Lightning payment processing not yet fully implemented. "
|
||||
"Requires Lightning node integration and invoice verification."
|
||||
)
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
amount_msats: int,
|
||||
currency: str,
|
||||
refund_address: str | None,
|
||||
metadata: dict[str, str | None],
|
||||
session: AsyncSession,
|
||||
) -> dict[str, str]:
|
||||
if not refund_address:
|
||||
raise ValueError("Refund address required for Lightning refunds")
|
||||
|
||||
logger.info(
|
||||
"Processing Lightning refund",
|
||||
extra={"amount_msats": amount_msats, "refund_address": refund_address},
|
||||
)
|
||||
|
||||
# TODO: Full implementation requires:
|
||||
# 1. Lightning node integration
|
||||
# 2. Create outgoing invoice or use keysend
|
||||
# 3. Send payment to refund_address
|
||||
# 4. Handle payment failures and retries
|
||||
# 5. Track payment status and confirmations
|
||||
#
|
||||
# Example flow:
|
||||
# 1. Validate refund_address (Lightning address or node pubkey)
|
||||
# 2. Create invoice or use keysend if supported
|
||||
# 3. Send payment via Lightning node
|
||||
# 4. Wait for payment confirmation
|
||||
# 5. Return payment hash/preimage
|
||||
|
||||
raise NotImplementedError(
|
||||
"Lightning refund processing not yet fully implemented. "
|
||||
"Requires Lightning node integration and payment sending capabilities."
|
||||
)
|
||||
|
||||
def extract_metadata(self, payment_data: str) -> dict[str, str | None]:
|
||||
payment_data = payment_data.strip()
|
||||
if payment_data.startswith("lightning:") or payment_data.startswith("LIGHTNING:"):
|
||||
payment_data = payment_data.split(":", 1)[1]
|
||||
|
||||
# TODO: Decode BOLT11 invoice to extract:
|
||||
# - Amount
|
||||
# - Expiry timestamp
|
||||
# - Payment hash
|
||||
# - Description
|
||||
# - Network (mainnet/testnet)
|
||||
#
|
||||
# Can use bolt11 library:
|
||||
# from bolt11 import decode
|
||||
# invoice = decode(payment_data)
|
||||
# return {
|
||||
# "amount_msats": invoice.amount_msat,
|
||||
# "expiry": invoice.expiry,
|
||||
# "payment_hash": invoice.payment_hash,
|
||||
# "description": invoice.description,
|
||||
# "network": invoice.network,
|
||||
# }
|
||||
|
||||
return {
|
||||
"invoice": payment_data,
|
||||
"currency": "sat",
|
||||
}
|
||||
145
routstr/payment/methods/usdt.py
Normal file
145
routstr/payment/methods/usdt.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import re
|
||||
|
||||
from ...core.db import ApiKey, AsyncSession
|
||||
from ...core.logging import get_logger
|
||||
from ..payment_methods import PaymentResult, PaymentMethod
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class USDTPaymentMethod(PaymentMethod):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "usdt"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "USDT (Tether)"
|
||||
|
||||
async def validate_payment_data(self, payment_data: str) -> bool:
|
||||
if not payment_data:
|
||||
return False
|
||||
|
||||
payment_data = payment_data.strip()
|
||||
|
||||
# USDT can be on multiple chains: Ethereum (ERC-20), Tron (TRC-20), etc.
|
||||
# For now, we'll accept transaction hashes or payment addresses
|
||||
# Ethereum address format: 0x followed by 40 hex characters
|
||||
eth_address_pattern = r"^0x[a-fA-F0-9]{40}$"
|
||||
# Tron address format: T followed by 33 alphanumeric characters
|
||||
tron_address_pattern = r"^T[A-Za-z1-9]{33}$"
|
||||
# Transaction hash: 64 hex characters (Ethereum) or 64 hex (Tron)
|
||||
tx_hash_pattern = r"^[a-fA-F0-9]{64}$"
|
||||
|
||||
return bool(
|
||||
re.match(eth_address_pattern, payment_data)
|
||||
or re.match(tron_address_pattern, payment_data)
|
||||
or re.match(tx_hash_pattern, payment_data)
|
||||
)
|
||||
|
||||
async def process_payment(
|
||||
self, payment_data: str, key: ApiKey, session: AsyncSession
|
||||
) -> PaymentResult:
|
||||
if not await self.validate_payment_data(payment_data):
|
||||
raise ValueError("Invalid USDT payment data format")
|
||||
|
||||
logger.info(
|
||||
"Processing USDT payment",
|
||||
extra={"payment_data_preview": payment_data[:50]},
|
||||
)
|
||||
|
||||
# TODO: Full implementation requires:
|
||||
# 1. Blockchain RPC integration (Ethereum/Tron node)
|
||||
# 2. Transaction verification and confirmation tracking
|
||||
# 3. Amount extraction from transaction
|
||||
# 4. USDT contract address verification
|
||||
# 5. Support for multiple chains (Ethereum, Tron, Polygon, etc.)
|
||||
# 6. Exchange rate conversion (USDT -> BTC/sats)
|
||||
# 7. Atomic balance updates after confirmation
|
||||
# 8. Handling of reorgs and chain splits
|
||||
#
|
||||
# Example libraries to consider:
|
||||
# - web3.py (for Ethereum)
|
||||
# - tronpy (for Tron)
|
||||
# - Multiple confirmations required (typically 6+ for Ethereum, 20+ for Tron)
|
||||
#
|
||||
# Example flow:
|
||||
# 1. Parse payment_data (address or transaction hash)
|
||||
# 2. If address: monitor for incoming USDT transfers
|
||||
# 3. If tx_hash: fetch and verify transaction
|
||||
# 4. Verify USDT contract address matches
|
||||
# 5. Extract amount from transaction
|
||||
# 6. Wait for required confirmations
|
||||
# 7. Convert USDT amount to BTC/sats using exchange rate
|
||||
# 8. Credit balance atomically
|
||||
# 9. Store transaction hash in metadata to prevent double-spending
|
||||
|
||||
raise NotImplementedError(
|
||||
"USDT payment processing not yet fully implemented. "
|
||||
"Requires blockchain RPC integration, transaction verification, "
|
||||
"and exchange rate conversion."
|
||||
)
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
amount_msats: int,
|
||||
currency: str,
|
||||
refund_address: str | None,
|
||||
metadata: dict[str, str | None],
|
||||
session: AsyncSession,
|
||||
) -> dict[str, str]:
|
||||
if not refund_address:
|
||||
raise ValueError("Refund address required for USDT refunds")
|
||||
|
||||
if not await self.validate_payment_data(refund_address):
|
||||
raise ValueError("Invalid USDT refund address format")
|
||||
|
||||
logger.info(
|
||||
"Processing USDT refund",
|
||||
extra={"amount_msats": amount_msats, "refund_address": refund_address},
|
||||
)
|
||||
|
||||
# TODO: Full implementation requires:
|
||||
# 1. Blockchain RPC integration
|
||||
# 2. Wallet/key management for sending USDT
|
||||
# 3. Convert BTC/sats to USDT using exchange rate
|
||||
# 4. Create and sign USDT transfer transaction
|
||||
# 5. Broadcast transaction to network
|
||||
# 6. Track transaction status and confirmations
|
||||
# 7. Handle gas fees (for Ethereum) or energy (for Tron)
|
||||
# 8. Support for multiple chains
|
||||
#
|
||||
# Example flow:
|
||||
# 1. Convert amount_msats to USDT using current exchange rate
|
||||
# 2. Determine chain from refund_address format
|
||||
# 3. Load wallet/private key for that chain
|
||||
# 4. Create USDT transfer transaction
|
||||
# 5. Sign transaction
|
||||
# 6. Broadcast to network
|
||||
# 7. Return transaction hash
|
||||
|
||||
raise NotImplementedError(
|
||||
"USDT refund processing not yet fully implemented. "
|
||||
"Requires blockchain RPC integration, wallet management, "
|
||||
"and transaction broadcasting capabilities."
|
||||
)
|
||||
|
||||
def extract_metadata(self, payment_data: str) -> dict[str, str | None]:
|
||||
payment_data = payment_data.strip()
|
||||
|
||||
# Determine chain based on address format
|
||||
chain = None
|
||||
if payment_data.startswith("0x"):
|
||||
chain = "ethereum"
|
||||
elif payment_data.startswith("T"):
|
||||
chain = "tron"
|
||||
elif len(payment_data) == 64:
|
||||
# Transaction hash - would need to query blockchain to determine chain
|
||||
chain = "unknown"
|
||||
|
||||
return {
|
||||
"address": payment_data if not payment_data.startswith("0x") or len(payment_data) == 42 else None,
|
||||
"tx_hash": payment_data if len(payment_data) == 64 else None,
|
||||
"chain": chain,
|
||||
"currency": "usdt",
|
||||
}
|
||||
48
routstr/payment/payment_methods.py
Normal file
48
routstr/payment/payment_methods.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TypedDict
|
||||
|
||||
from ..core.db import ApiKey, AsyncSession
|
||||
|
||||
|
||||
class PaymentResult(TypedDict):
|
||||
amount_msats: int
|
||||
currency: str
|
||||
payment_method: str
|
||||
metadata: dict[str, str | None]
|
||||
|
||||
|
||||
class PaymentMethod(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def display_name(self) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def validate_payment_data(self, payment_data: str) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def process_payment(
|
||||
self, payment_data: str, key: ApiKey, session: AsyncSession
|
||||
) -> PaymentResult:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def refund_payment(
|
||||
self,
|
||||
amount_msats: int,
|
||||
currency: str,
|
||||
refund_address: str | None,
|
||||
metadata: dict[str, str | None],
|
||||
session: AsyncSession,
|
||||
) -> dict[str, str]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def extract_metadata(self, payment_data: str) -> dict[str, str | None]:
|
||||
pass
|
||||
35
routstr/payment/registry.py
Normal file
35
routstr/payment/registry.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from .methods.cashu import CashuPaymentMethod
|
||||
from .methods.lightning import BitcoinLightningPaymentMethod
|
||||
from .methods.usdt import USDTPaymentMethod
|
||||
from .payment_methods import PaymentMethod
|
||||
|
||||
_payment_methods: dict[str, PaymentMethod] = {}
|
||||
|
||||
|
||||
def register_payment_method(method: PaymentMethod) -> None:
|
||||
_payment_methods[method.name] = method
|
||||
|
||||
|
||||
def get_payment_method(name: str) -> PaymentMethod | None:
|
||||
return _payment_methods.get(name)
|
||||
|
||||
|
||||
def get_all_payment_methods() -> dict[str, PaymentMethod]:
|
||||
return _payment_methods.copy()
|
||||
|
||||
|
||||
def detect_payment_method(payment_data: str) -> PaymentMethod | None:
|
||||
for method in _payment_methods.values():
|
||||
try:
|
||||
if method.validate_payment_data(payment_data):
|
||||
return method
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def initialize_payment_methods() -> None:
|
||||
if not _payment_methods:
|
||||
register_payment_method(CashuPaymentMethod())
|
||||
register_payment_method(BitcoinLightningPaymentMethod())
|
||||
register_payment_method(USDTPaymentMethod())
|
||||
@@ -104,54 +104,44 @@ async def swap_to_primary_mint(
|
||||
|
||||
|
||||
async def credit_balance(
|
||||
cashu_token: str, key: db.ApiKey, session: db.AsyncSession
|
||||
payment_data: str,
|
||||
key: db.ApiKey,
|
||||
session: db.AsyncSession,
|
||||
payment_method: str | None = None,
|
||||
) -> int:
|
||||
from .payment.registry import detect_payment_method, get_payment_method, initialize_payment_methods
|
||||
|
||||
initialize_payment_methods()
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Starting token redemption",
|
||||
extra={"token_preview": cashu_token[:50]},
|
||||
"credit_balance: Starting payment processing",
|
||||
extra={"payment_data_preview": payment_data[:50], "payment_method": payment_method},
|
||||
)
|
||||
|
||||
method = None
|
||||
if payment_method:
|
||||
method = get_payment_method(payment_method)
|
||||
if not method:
|
||||
raise ValueError(f"Unknown payment method: {payment_method}")
|
||||
else:
|
||||
method = detect_payment_method(payment_data)
|
||||
if not method:
|
||||
raise ValueError("Could not detect payment method from payment data")
|
||||
|
||||
try:
|
||||
amount, unit, mint_url = await recieve_token(cashu_token)
|
||||
result = await method.process_payment(payment_data, key, session)
|
||||
logger.info(
|
||||
"credit_balance: Token redeemed successfully",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
"credit_balance: Payment processed successfully",
|
||||
extra={
|
||||
"amount_msats": result["amount_msats"],
|
||||
"currency": result["currency"],
|
||||
"payment_method": result["payment_method"],
|
||||
},
|
||||
)
|
||||
|
||||
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
|
||||
return result["amount_msats"]
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"credit_balance: Error during token redemption",
|
||||
"credit_balance: Error during payment processing",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user