mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Refactor: Introduce payment method abstraction
This commit refactors the payment processing logic by introducing an abstract PaymentMethod class. This allows for easier integration of new payment methods like Lightning and USDT in the future. The existing Cashu token logic is adapted to fit this new structure. Co-authored-by: db2002dominic <db2002dominic@gmail.com>
This commit is contained in:
co-authored by
db2002dominic
parent
14ae4ecce3
commit
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