mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 15:26:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7559546561 |
+42
-30
@@ -15,6 +15,7 @@ from .payment.cost_caculation import (
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from .payment.method_implementations import get_payment_method
|
||||
from .wallet import credit_balance, deserialize_token_from_string
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -104,25 +105,26 @@ async def validate_bearer_key(
|
||||
extra={"key_preview": bearer_key[:10] + "..."},
|
||||
)
|
||||
|
||||
if bearer_key.startswith("cashu"):
|
||||
payment_method = get_payment_method(bearer_key)
|
||||
if payment_method:
|
||||
logger.debug(
|
||||
"Processing Cashu token",
|
||||
f"Processing {payment_method.method_type} payment",
|
||||
extra={
|
||||
"token_preview": bearer_key[:20] + "...",
|
||||
"token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key,
|
||||
"payment_preview": bearer_key[:20] + "...",
|
||||
"method_type": payment_method.method_type,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
|
||||
token_obj = deserialize_token_from_string(bearer_key)
|
||||
metadata = await payment_method.validate_payment_data(bearer_key, session)
|
||||
logger.debug(
|
||||
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
|
||||
"Generated payment hash", extra={"hash_preview": hashed_key[:16] + "..."}
|
||||
)
|
||||
|
||||
if existing_key := await session.get(ApiKey, hashed_key):
|
||||
logger.info(
|
||||
"Existing Cashu token found",
|
||||
f"Existing {payment_method.method_type} payment found",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"balance": existing_key.balance,
|
||||
@@ -133,7 +135,7 @@ async def validate_bearer_key(
|
||||
if key_expiry_time is not None:
|
||||
existing_key.key_expiry_time = key_expiry_time
|
||||
logger.debug(
|
||||
"Updated key expiry time for existing Cashu key",
|
||||
"Updated key expiry time for existing payment",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"expiry_time": key_expiry_time,
|
||||
@@ -143,7 +145,7 @@ async def validate_bearer_key(
|
||||
if refund_address is not None:
|
||||
existing_key.refund_address = refund_address
|
||||
logger.debug(
|
||||
"Updated refund address for existing Cashu key",
|
||||
"Updated refund address for existing payment",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"refund_address_preview": refund_address[:20] + "..."
|
||||
@@ -155,19 +157,25 @@ async def validate_bearer_key(
|
||||
return existing_key
|
||||
|
||||
logger.info(
|
||||
"Creating new Cashu token entry",
|
||||
f"Creating new {payment_method.method_type} payment entry",
|
||||
extra={
|
||||
"hash_preview": hashed_key[:16] + "...",
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
"method_type": payment_method.method_type,
|
||||
},
|
||||
)
|
||||
if token_obj.mint in settings.cashu_mints:
|
||||
refund_currency = token_obj.unit
|
||||
refund_mint_url = token_obj.mint
|
||||
else:
|
||||
refund_currency = "sat"
|
||||
refund_mint_url = settings.primary_mint
|
||||
|
||||
refund_currency = metadata.get("currency") or "sat"
|
||||
refund_mint_url = metadata.get("mint_url") or settings.primary_mint
|
||||
|
||||
if payment_method.method_type == "cashu":
|
||||
if metadata.get("mint_url") in settings.cashu_mints:
|
||||
refund_currency = metadata.get("currency") or "sat"
|
||||
refund_mint_url = metadata.get("mint_url") or settings.primary_mint
|
||||
else:
|
||||
refund_currency = "sat"
|
||||
refund_mint_url = settings.primary_mint
|
||||
|
||||
new_key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
@@ -199,22 +207,24 @@ async def validate_bearer_key(
|
||||
return existing_key
|
||||
|
||||
logger.debug(
|
||||
"New key created, starting token redemption",
|
||||
extra={"key_hash": hashed_key[:8] + "..."},
|
||||
"New key created, starting payment processing",
|
||||
extra={"key_hash": hashed_key[:8] + "...", "method_type": payment_method.method_type},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"AUTH: About to call credit_balance",
|
||||
extra={"token_preview": bearer_key[:50]},
|
||||
f"AUTH: About to call {payment_method.method_type} credit_balance",
|
||||
extra={"payment_preview": bearer_key[:50]},
|
||||
)
|
||||
try:
|
||||
msats = await credit_balance(bearer_key, new_key, session)
|
||||
payment_result = await payment_method.credit_balance(bearer_key, new_key, session)
|
||||
msats = payment_result["amount_msats"]
|
||||
logger.info(
|
||||
"AUTH: credit_balance returned successfully", extra={"msats": msats}
|
||||
f"AUTH: {payment_method.method_type} credit_balance returned successfully",
|
||||
extra={"msats": msats},
|
||||
)
|
||||
except Exception as credit_error:
|
||||
logger.error(
|
||||
"AUTH: credit_balance failed",
|
||||
f"AUTH: {payment_method.method_type} credit_balance failed",
|
||||
extra={
|
||||
"error": str(credit_error),
|
||||
"error_type": type(credit_error).__name__,
|
||||
@@ -224,40 +234,42 @@ async def validate_bearer_key(
|
||||
|
||||
if msats <= 0:
|
||||
logger.error(
|
||||
"Token redemption returned zero or negative amount",
|
||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
||||
"Payment processing returned zero or negative amount",
|
||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "...", "method_type": payment_method.method_type},
|
||||
)
|
||||
raise Exception("Token redemption failed")
|
||||
raise Exception("Payment processing failed")
|
||||
|
||||
await session.refresh(new_key)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"New Cashu token successfully redeemed and stored",
|
||||
f"New {payment_method.method_type} payment successfully processed and stored",
|
||||
extra={
|
||||
"key_hash": hashed_key[:8] + "...",
|
||||
"redeemed_msats": msats,
|
||||
"final_balance": new_key.balance,
|
||||
"method_type": payment_method.method_type,
|
||||
},
|
||||
)
|
||||
|
||||
return new_key
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Cashu token redemption failed",
|
||||
f"{payment_method.method_type} payment processing failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"token_preview": bearer_key[:20] + "..."
|
||||
"payment_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
"method_type": payment_method.method_type,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Invalid or expired Cashu key: {str(e)}",
|
||||
"message": f"Invalid or expired {payment_method.method_type} payment: {str(e)}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
|
||||
+52
-23
@@ -10,6 +10,7 @@ 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 .payment.method_implementations import get_payment_method
|
||||
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
|
||||
|
||||
router = APIRouter()
|
||||
@@ -87,21 +88,25 @@ async def topup_wallet_endpoint(
|
||||
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.")
|
||||
raise HTTPException(status_code=400, detail="A payment 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")
|
||||
payment_data = cashu_token.replace("\n", "").replace("\r", "").replace("\t", "")
|
||||
|
||||
payment_method = get_payment_method(payment_data)
|
||||
if not payment_method:
|
||||
raise HTTPException(status_code=400, detail="Invalid payment format")
|
||||
|
||||
try:
|
||||
amount_msats = await credit_balance(cashu_token, key, session)
|
||||
payment_result = await payment_method.credit_balance(payment_data, key, session)
|
||||
amount_msats = payment_result["amount_msats"]
|
||||
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 format")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Failed to redeem token")
|
||||
raise HTTPException(status_code=400, detail="Failed to process payment")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
return {"msats": amount_msats}
|
||||
@@ -165,24 +170,48 @@ async def refund_wallet_endpoint(
|
||||
elif remaining_balance <= 0:
|
||||
raise HTTPException(status_code=400, detail="No balance to refund")
|
||||
|
||||
# Determine payment method from stored metadata
|
||||
# For backward compatibility, default to cashu if refund_mint_url is set
|
||||
payment_method = None
|
||||
if key.refund_mint_url:
|
||||
# Assume cashu if mint_url is present
|
||||
from .payment.method_implementations import CashuTokenPaymentMethod
|
||||
payment_method = CashuTokenPaymentMethod()
|
||||
else:
|
||||
# Try to detect payment method from refund_address format
|
||||
if key.refund_address:
|
||||
if key.refund_address.startswith("ln"):
|
||||
from .payment.method_implementations import BitcoinLightningPaymentMethod
|
||||
payment_method = BitcoinLightningPaymentMethod()
|
||||
else:
|
||||
# Default to cashu for backward compatibility
|
||||
from .payment.method_implementations import CashuTokenPaymentMethod
|
||||
payment_method = CashuTokenPaymentMethod()
|
||||
else:
|
||||
# Default to cashu for backward compatibility
|
||||
from .payment.method_implementations import CashuTokenPaymentMethod
|
||||
payment_method = CashuTokenPaymentMethod()
|
||||
|
||||
# Perform refund operation first, before modifying balance
|
||||
try:
|
||||
if key.refund_address:
|
||||
from .core.settings import settings as global_settings
|
||||
refund_result = await payment_method.refund_balance(
|
||||
key, remaining_balance_msats, session
|
||||
)
|
||||
|
||||
if not refund_result["success"]:
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
|
||||
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}
|
||||
result: dict[str, str] = {}
|
||||
if payment_method.method_type == "cashu":
|
||||
if key.refund_address:
|
||||
result["recipient"] = key.refund_address
|
||||
else:
|
||||
result["token"] = refund_result["refund_identifier"] or ""
|
||||
elif payment_method.method_type == "lightning":
|
||||
result["recipient"] = key.refund_address or ""
|
||||
result["invoice"] = refund_result["refund_identifier"] or ""
|
||||
elif payment_method.method_type == "usdt":
|
||||
result["transaction_hash"] = refund_result["refund_identifier"] or ""
|
||||
|
||||
if key.refund_currency == "sat":
|
||||
result["sats"] = str(remaining_balance_msats // 1000)
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from .methods import (
|
||||
PaymentMethod,
|
||||
PaymentMethodMetadata,
|
||||
PaymentResult,
|
||||
RefundResult,
|
||||
)
|
||||
from .method_implementations import (
|
||||
BitcoinLightningPaymentMethod,
|
||||
CashuTokenPaymentMethod,
|
||||
USDTTetherPaymentMethod,
|
||||
get_payment_method,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CostData",
|
||||
"CostDataError",
|
||||
"MaxCostData",
|
||||
"calculate_cost",
|
||||
"PaymentMethod",
|
||||
"PaymentMethodMetadata",
|
||||
"PaymentResult",
|
||||
"RefundResult",
|
||||
"BitcoinLightningPaymentMethod",
|
||||
"CashuTokenPaymentMethod",
|
||||
"USDTTetherPaymentMethod",
|
||||
"get_payment_method",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
from typing import TypedDict
|
||||
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core.db import ApiKey
|
||||
from ..core.settings import settings
|
||||
from ..wallet import credit_balance as wallet_credit_balance, send_token, send_to_lnurl
|
||||
from .methods import (
|
||||
PaymentMethod,
|
||||
PaymentMethodMetadata,
|
||||
PaymentResult,
|
||||
RefundResult,
|
||||
)
|
||||
|
||||
|
||||
class CashuTokenPaymentMethod(PaymentMethod):
|
||||
"""Payment method for Cashu eCash tokens."""
|
||||
|
||||
@property
|
||||
def method_type(self) -> str:
|
||||
return "cashu"
|
||||
|
||||
async def validate_payment_data(
|
||||
self, payment_data: str, session: AsyncSession
|
||||
) -> PaymentMethodMetadata:
|
||||
if not payment_data or len(payment_data) < 10 or "cashu" not in payment_data:
|
||||
raise ValueError("Invalid Cashu token format")
|
||||
|
||||
try:
|
||||
from ..wallet import deserialize_token_from_string
|
||||
|
||||
token_obj = deserialize_token_from_string(payment_data)
|
||||
return PaymentMethodMetadata(
|
||||
mint_url=token_obj.mint,
|
||||
currency=token_obj.unit,
|
||||
)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid Cashu token: {e}") from e
|
||||
|
||||
async def credit_balance(
|
||||
self, payment_data: str, key: ApiKey, session: AsyncSession
|
||||
) -> PaymentResult:
|
||||
from ..wallet import deserialize_token_from_string
|
||||
|
||||
token_obj = deserialize_token_from_string(payment_data)
|
||||
amount_msats = await wallet_credit_balance(payment_data, key, session)
|
||||
metadata = PaymentMethodMetadata(
|
||||
mint_url=token_obj.mint,
|
||||
currency=token_obj.unit,
|
||||
)
|
||||
return PaymentResult(amount_msats=amount_msats, metadata=metadata)
|
||||
|
||||
async def refund_balance(
|
||||
self, key: ApiKey, amount_msats: int, session: AsyncSession
|
||||
) -> RefundResult:
|
||||
if key.refund_address:
|
||||
from ..core.settings import settings as global_settings
|
||||
|
||||
await send_to_lnurl(
|
||||
amount_msats // 1000 if key.refund_currency == "sat" else amount_msats,
|
||||
key.refund_currency or "sat",
|
||||
key.refund_mint_url or global_settings.primary_mint,
|
||||
key.refund_address,
|
||||
)
|
||||
return RefundResult(
|
||||
success=True,
|
||||
refund_identifier=key.refund_address,
|
||||
metadata=PaymentMethodMetadata(
|
||||
mint_url=key.refund_mint_url,
|
||||
currency=key.refund_currency,
|
||||
),
|
||||
)
|
||||
else:
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
remaining_balance = (
|
||||
amount_msats // 1000 if refund_currency == "sat" else amount_msats
|
||||
)
|
||||
token = await send_token(
|
||||
remaining_balance, refund_currency, key.refund_mint_url
|
||||
)
|
||||
return RefundResult(
|
||||
success=True,
|
||||
refund_identifier=token,
|
||||
metadata=PaymentMethodMetadata(
|
||||
mint_url=key.refund_mint_url,
|
||||
currency=refund_currency,
|
||||
),
|
||||
)
|
||||
|
||||
def can_handle(self, payment_data: str) -> bool:
|
||||
return (
|
||||
payment_data is not None
|
||||
and len(payment_data) >= 10
|
||||
and payment_data.startswith("cashu")
|
||||
)
|
||||
|
||||
|
||||
class BitcoinLightningPaymentMethod(PaymentMethod):
|
||||
"""Payment method for Bitcoin Lightning Network invoices.
|
||||
|
||||
To fully implement this payment method, you would need to:
|
||||
1. Integrate with a Lightning node (e.g., LND, CLN, or LDK)
|
||||
2. Implement invoice validation and payment verification
|
||||
3. Set up webhook handling for payment confirmations
|
||||
4. Implement proper invoice generation for refunds
|
||||
5. Handle payment routing and channel management
|
||||
6. Add support for keysend payments
|
||||
7. Implement proper error handling for payment failures
|
||||
8. Add rate limiting and anti-spam measures
|
||||
9. Store invoice preimages for verification
|
||||
10. Implement proper database schema for tracking Lightning payments
|
||||
"""
|
||||
|
||||
@property
|
||||
def method_type(self) -> str:
|
||||
return "lightning"
|
||||
|
||||
async def validate_payment_data(
|
||||
self, payment_data: str, session: AsyncSession
|
||||
) -> PaymentMethodMetadata:
|
||||
if not payment_data or not payment_data.startswith("ln"):
|
||||
raise ValueError("Invalid Lightning invoice format")
|
||||
|
||||
try:
|
||||
from .lnurl import parse_lightning_invoice_amount
|
||||
|
||||
amount_msats = parse_lightning_invoice_amount(payment_data, currency="msat")
|
||||
return PaymentMethodMetadata(
|
||||
invoice=payment_data,
|
||||
currency="msat",
|
||||
)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid Lightning invoice: {e}") from e
|
||||
|
||||
async def credit_balance(
|
||||
self, payment_data: str, key: ApiKey, session: AsyncSession
|
||||
) -> PaymentResult:
|
||||
# TODO: Full implementation requires:
|
||||
# 1. Verify invoice is paid by checking with Lightning node
|
||||
# 2. Extract payment amount from invoice
|
||||
# 3. Verify payment preimage matches invoice
|
||||
# 4. Check for double-spending
|
||||
# 5. Update database with payment confirmation
|
||||
|
||||
# Pseudo-implementation:
|
||||
from .lnurl import parse_lightning_invoice_amount
|
||||
|
||||
amount_msats = parse_lightning_invoice_amount(payment_data, currency="msat")
|
||||
|
||||
# In real implementation, you would:
|
||||
# - Connect to Lightning node: lnd_client = LndClient(...)
|
||||
# - Verify payment: await lnd_client.verify_payment(payment_data)
|
||||
# - Get payment details: payment = await lnd_client.get_payment(payment_hash)
|
||||
# - Check if already processed: await check_payment_already_processed(payment_hash)
|
||||
|
||||
metadata = PaymentMethodMetadata(
|
||||
invoice=payment_data,
|
||||
currency="msat",
|
||||
)
|
||||
return PaymentResult(amount_msats=amount_msats, metadata=metadata)
|
||||
|
||||
async def refund_balance(
|
||||
self, key: ApiKey, amount_msats: int, session: AsyncSession
|
||||
) -> RefundResult:
|
||||
# TODO: Full implementation requires:
|
||||
# 1. Generate Lightning invoice for refund amount
|
||||
# 2. Store invoice in database with refund mapping
|
||||
# 3. Pay invoice using Lightning node
|
||||
# 4. Handle payment failures and retries
|
||||
# 5. Update refund status in database
|
||||
|
||||
# Pseudo-implementation:
|
||||
if not key.refund_address:
|
||||
raise ValueError("No refund address configured for Lightning refund")
|
||||
|
||||
# In real implementation, you would:
|
||||
# - Generate invoice: invoice = await lnd_client.add_invoice(amount_msats, memo="Refund")
|
||||
# - Pay invoice: await lnd_client.send_payment(key.refund_address, amount_msats)
|
||||
# - Store refund record: await store_refund_record(key.hashed_key, invoice.payment_hash)
|
||||
|
||||
refund_invoice = f"lnbc{amount_msats // 1000}u..." # Pseudo invoice
|
||||
return RefundResult(
|
||||
success=True,
|
||||
refund_identifier=refund_invoice,
|
||||
metadata=PaymentMethodMetadata(
|
||||
invoice=refund_invoice,
|
||||
currency="msat",
|
||||
),
|
||||
)
|
||||
|
||||
def can_handle(self, payment_data: str) -> bool:
|
||||
return payment_data is not None and payment_data.startswith("ln")
|
||||
|
||||
|
||||
class USDTTetherPaymentMethod(PaymentMethod):
|
||||
"""Payment method for USDT (Tether) on various blockchains.
|
||||
|
||||
To fully implement this payment method, you would need to:
|
||||
1. Choose blockchain(s) to support (Ethereum, Tron, Polygon, etc.)
|
||||
2. Set up blockchain node connections (Infura, Alchemy, or self-hosted)
|
||||
3. Implement USDT contract interaction (ERC-20 or TRC-20)
|
||||
4. Set up wallet for receiving payments
|
||||
5. Implement transaction monitoring and confirmation tracking
|
||||
6. Add support for multiple networks (mainnet, testnet)
|
||||
7. Implement proper gas fee estimation and handling
|
||||
8. Add transaction verification and double-spend prevention
|
||||
9. Implement refund mechanism (send USDT back to user address)
|
||||
10. Add proper error handling for network issues and failed transactions
|
||||
11. Implement rate limiting and anti-spam measures
|
||||
12. Store transaction hashes and block confirmations in database
|
||||
13. Add support for different USDT versions (USDT, USDT.e, etc.)
|
||||
"""
|
||||
|
||||
@property
|
||||
def method_type(self) -> str:
|
||||
return "usdt"
|
||||
|
||||
async def validate_payment_data(
|
||||
self, payment_data: str, session: AsyncSession
|
||||
) -> PaymentMethodMetadata:
|
||||
# TODO: Full implementation requires:
|
||||
# 1. Validate transaction hash format for chosen blockchain
|
||||
# 2. Verify transaction exists on blockchain
|
||||
# 3. Check transaction is confirmed (sufficient block confirmations)
|
||||
# 4. Verify transaction is to our wallet address
|
||||
# 5. Verify transaction amount matches expected amount
|
||||
# 6. Check transaction hasn't been used before (double-spend prevention)
|
||||
|
||||
# Pseudo-implementation:
|
||||
if not payment_data or len(payment_data) < 40:
|
||||
raise ValueError("Invalid transaction hash format")
|
||||
|
||||
# In real implementation, you would:
|
||||
# - Validate format: if not re.match(r'^0x[a-fA-F0-9]{64}$', payment_data): raise ValueError(...)
|
||||
# - Check transaction: tx = await web3.eth.get_transaction(payment_data)
|
||||
# - Verify recipient: assert tx['to'].lower() == our_wallet_address.lower()
|
||||
# - Check confirmations: confirmations = await get_confirmations(tx['blockNumber'])
|
||||
# - Verify amount: assert tx['value'] == expected_amount
|
||||
|
||||
return PaymentMethodMetadata(
|
||||
transaction_hash=payment_data,
|
||||
network="ethereum", # or "tron", "polygon", etc.
|
||||
contract_address="0xdAC17F958D2ee523a2206206994597C13D831ec7", # USDT on Ethereum
|
||||
)
|
||||
|
||||
async def credit_balance(
|
||||
self, payment_data: str, key: ApiKey, session: AsyncSession
|
||||
) -> PaymentResult:
|
||||
# TODO: Full implementation requires:
|
||||
# 1. Fetch transaction details from blockchain
|
||||
# 2. Convert USDT amount to millisatoshis (using exchange rate)
|
||||
# 3. Verify transaction is confirmed and not already processed
|
||||
# 4. Store transaction record in database
|
||||
# 5. Update balance atomically
|
||||
|
||||
# Pseudo-implementation:
|
||||
# In real implementation, you would:
|
||||
# - Get transaction: tx = await web3.eth.get_transaction_receipt(payment_data)
|
||||
# - Parse USDT transfer event: usdt_amount = parse_usdt_transfer_event(tx['logs'])
|
||||
# - Get exchange rate: rate = await get_usdt_to_btc_rate()
|
||||
# - Convert to msats: amount_msats = int(usdt_amount * rate * 100_000_000_000)
|
||||
# - Check if processed: if await is_transaction_processed(payment_data): raise ValueError("Already processed")
|
||||
# - Store transaction: await store_transaction_record(key.hashed_key, payment_data, usdt_amount)
|
||||
|
||||
amount_msats = 1000000 # Pseudo amount
|
||||
metadata = PaymentMethodMetadata(
|
||||
transaction_hash=payment_data,
|
||||
network="ethereum",
|
||||
contract_address="0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
)
|
||||
return PaymentResult(amount_msats=amount_msats, metadata=metadata)
|
||||
|
||||
async def refund_balance(
|
||||
self, key: ApiKey, amount_msats: int, session: AsyncSession
|
||||
) -> RefundResult:
|
||||
# TODO: Full implementation requires:
|
||||
# 1. Convert millisatoshis to USDT using exchange rate
|
||||
# 2. Get user's refund address (stored in key metadata)
|
||||
# 3. Estimate gas fees for transaction
|
||||
# 4. Send USDT transaction to user's address
|
||||
# 5. Wait for transaction confirmation
|
||||
# 6. Store refund transaction hash in database
|
||||
# 7. Handle transaction failures and retries
|
||||
|
||||
# Pseudo-implementation:
|
||||
if not key.refund_address:
|
||||
raise ValueError("No refund address configured for USDT refund")
|
||||
|
||||
# In real implementation, you would:
|
||||
# - Get exchange rate: rate = await get_btc_to_usdt_rate()
|
||||
# - Convert to USDT: usdt_amount = (amount_msats / 100_000_000_000) * rate
|
||||
# - Build transaction: tx = usdt_contract.functions.transfer(key.refund_address, usdt_amount)
|
||||
# - Estimate gas: gas = await tx.estimate_gas({'from': our_wallet_address})
|
||||
# - Send transaction: tx_hash = await tx.transact({'from': our_wallet_address, 'gas': gas})
|
||||
# - Wait for confirmation: await wait_for_confirmation(tx_hash)
|
||||
# - Store refund: await store_refund_record(key.hashed_key, tx_hash, usdt_amount)
|
||||
|
||||
refund_tx_hash = "0x" + "0" * 64 # Pseudo transaction hash
|
||||
return RefundResult(
|
||||
success=True,
|
||||
refund_identifier=refund_tx_hash,
|
||||
metadata=PaymentMethodMetadata(
|
||||
transaction_hash=refund_tx_hash,
|
||||
network="ethereum",
|
||||
contract_address="0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
),
|
||||
)
|
||||
|
||||
def can_handle(self, payment_data: str) -> bool:
|
||||
# In real implementation, you might check for specific prefixes or formats
|
||||
# For now, this is a catch-all that should be called after other methods
|
||||
return False # Disabled by default, enable when fully implemented
|
||||
|
||||
|
||||
def get_payment_method(payment_data: str) -> PaymentMethod | None:
|
||||
"""Get the appropriate payment method for the given payment data.
|
||||
|
||||
Args:
|
||||
payment_data: Payment data string to identify
|
||||
|
||||
Returns:
|
||||
PaymentMethod instance or None if no method can handle it
|
||||
"""
|
||||
methods: list[PaymentMethod] = [
|
||||
CashuTokenPaymentMethod(),
|
||||
BitcoinLightningPaymentMethod(),
|
||||
USDTTetherPaymentMethod(),
|
||||
]
|
||||
|
||||
for method in methods:
|
||||
if method.can_handle(payment_data):
|
||||
return method
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,101 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TypedDict
|
||||
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core.db import ApiKey
|
||||
|
||||
|
||||
class PaymentMethodMetadata(TypedDict, total=False):
|
||||
"""Metadata specific to each payment method type."""
|
||||
mint_url: str | None
|
||||
currency: str | None
|
||||
invoice: str | None
|
||||
transaction_hash: str | None
|
||||
network: str | None
|
||||
contract_address: str | None
|
||||
|
||||
|
||||
class PaymentResult(TypedDict):
|
||||
"""Result of processing a payment."""
|
||||
amount_msats: int
|
||||
metadata: PaymentMethodMetadata
|
||||
|
||||
|
||||
class RefundResult(TypedDict):
|
||||
"""Result of processing a refund."""
|
||||
success: bool
|
||||
refund_identifier: str | None
|
||||
metadata: PaymentMethodMetadata
|
||||
|
||||
|
||||
class PaymentMethod(ABC):
|
||||
"""Abstract base class for payment methods used for temporary balances."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def method_type(self) -> str:
|
||||
"""Return the payment method type identifier (e.g., 'cashu', 'lightning', 'usdt')."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def validate_payment_data(
|
||||
self, payment_data: str, session: AsyncSession
|
||||
) -> PaymentMethodMetadata:
|
||||
"""Validate payment data and extract metadata.
|
||||
|
||||
Args:
|
||||
payment_data: Payment data string (token, invoice, etc.)
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
PaymentMethodMetadata with extracted information
|
||||
|
||||
Raises:
|
||||
ValueError: If payment data is invalid
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def credit_balance(
|
||||
self, payment_data: str, key: ApiKey, session: AsyncSession
|
||||
) -> PaymentResult:
|
||||
"""Credit balance from payment data.
|
||||
|
||||
Args:
|
||||
payment_data: Payment data string (token, invoice, etc.)
|
||||
key: API key to credit
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
PaymentResult with credited amount and metadata
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def refund_balance(
|
||||
self, key: ApiKey, amount_msats: int, session: AsyncSession
|
||||
) -> RefundResult:
|
||||
"""Refund remaining balance.
|
||||
|
||||
Args:
|
||||
key: API key with balance to refund
|
||||
amount_msats: Amount to refund in millisatoshis
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
RefundResult with refund details
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def can_handle(self, payment_data: str) -> bool:
|
||||
"""Check if this payment method can handle the given payment data.
|
||||
|
||||
Args:
|
||||
payment_data: Payment data string to check
|
||||
|
||||
Returns:
|
||||
True if this method can handle the payment data
|
||||
"""
|
||||
pass
|
||||
Reference in New Issue
Block a user