mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +02:00
Add api
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
DATABASE_URL=postgresql://xmrcheckout:xmrcheckout@localhost:5432/xmrcheckout
|
||||||
|
API_KEYS=change-me-1,change-me-2
|
||||||
|
COINGECKO_API_KEY=
|
||||||
|
FOUNDER_PAYMENT_ADDRESS=
|
||||||
|
FOUNDER_VIEW_KEY=
|
||||||
|
DONATIONS_ENABLED=false
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
COPY app /app/app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["/bin/sh", "-c", "gunicorn app.main:app -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 -w ${GUNICORN_WORKERS:-2}"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""API package."""
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from decimal import Decimal, ROUND_DOWN
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .btcpay_schemas import (
|
||||||
|
BtcpayInvoiceCreate,
|
||||||
|
BtcpayInvoiceStatusUpdate,
|
||||||
|
BtcpayWebhookCreate,
|
||||||
|
BtcpayWebhookUpdate,
|
||||||
|
)
|
||||||
|
from .btcpay_webhooks import dispatch_btcpay_webhooks
|
||||||
|
from .config import INVOICE_DEFAULT_EXPIRY_HOURS
|
||||||
|
from .config import QR_STORAGE_DIR
|
||||||
|
from .db import get_db
|
||||||
|
from .formatting import format_xmr_amount
|
||||||
|
from .models import BtcpayWebhook, Invoice, InvoiceTransfer, User
|
||||||
|
from .rates import get_xmr_rate
|
||||||
|
from .security import (
|
||||||
|
decrypt_api_key,
|
||||||
|
encrypt_secret,
|
||||||
|
generate_webhook_secret,
|
||||||
|
hash_api_key,
|
||||||
|
require_api_key,
|
||||||
|
)
|
||||||
|
from .subaddress_allocator import create_subaddress_for_user
|
||||||
|
from .webhooks import dispatch_webhooks
|
||||||
|
from .qr_codes import ensure_invoice_qr_png, resolve_qr_settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
BTCPAY_PAYMENT_METHOD = "XMR-CHAIN"
|
||||||
|
BTCPAY_PAYMENT_METHOD_ALIASES = {BTCPAY_PAYMENT_METHOD, "XMR", "XMR_CHAIN"}
|
||||||
|
BTCPAY_WEBHOOK_EVENTS = {
|
||||||
|
"InvoiceReceivedPayment",
|
||||||
|
"InvoicePaymentSettled",
|
||||||
|
"InvoiceProcessing",
|
||||||
|
"InvoiceExpired",
|
||||||
|
"InvoiceSettled",
|
||||||
|
"InvoiceInvalid",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_btcpay_user(
|
||||||
|
api_key: str = Depends(require_api_key),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
user = db.query(User).filter(User.api_key_hash == hash_api_key(api_key)).first()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User API key required",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _require_store(store_id: str, user: User) -> str:
|
||||||
|
if store_id != str(user.id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Store not found",
|
||||||
|
)
|
||||||
|
return store_id
|
||||||
|
|
||||||
|
|
||||||
|
def _store_payload(user: User) -> dict[str, Any]:
|
||||||
|
expiry_minutes = max(1, INVOICE_DEFAULT_EXPIRY_HOURS * 60)
|
||||||
|
return {
|
||||||
|
"id": str(user.id),
|
||||||
|
"name": "XMR Checkout",
|
||||||
|
"website": "",
|
||||||
|
"defaultCurrency": "XMR",
|
||||||
|
"invoiceExpiration": expiry_minutes,
|
||||||
|
"displayExpirationTimer": True,
|
||||||
|
"monitoringExpiration": expiry_minutes,
|
||||||
|
"speedPolicy": "MediumSpeed",
|
||||||
|
"paymentTolerance": 0,
|
||||||
|
"anyoneCanCreateInvoice": False,
|
||||||
|
"requiresRefundEmail": False,
|
||||||
|
"lightningAmountInSatoshi": False,
|
||||||
|
"lightningPrivateRouteHints": False,
|
||||||
|
"onChainWithLnInvoiceFallback": False,
|
||||||
|
"redirectAutomatically": False,
|
||||||
|
"showRecommendedFee": False,
|
||||||
|
"recommendedFeeBlockTarget": 0,
|
||||||
|
"defaultLang": "en",
|
||||||
|
"customLogo": "",
|
||||||
|
"customCSS": "",
|
||||||
|
"htmlTitle": "XMR Checkout",
|
||||||
|
"networkFeeMode": "Never",
|
||||||
|
"payJoinEnabled": False,
|
||||||
|
"lazyPaymentMethods": False,
|
||||||
|
"defaultPaymentMethod": BTCPAY_PAYMENT_METHOD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _invoice_checkout_link(invoice: Invoice, request: Request) -> str:
|
||||||
|
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||||
|
host = request.headers.get("x-forwarded-host", request.headers.get("host", ""))
|
||||||
|
if not host:
|
||||||
|
return f"/i/{invoice.id}"
|
||||||
|
return f"{proto}://{host}/i/{invoice.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_btcpay_payment_method(value: str) -> str:
|
||||||
|
normalized = value.strip().upper()
|
||||||
|
if normalized in BTCPAY_PAYMENT_METHOD_ALIASES:
|
||||||
|
return BTCPAY_PAYMENT_METHOD
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch_seconds(value: datetime | None) -> int:
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return int(value.timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_btcpay_amount(
|
||||||
|
amount: Decimal,
|
||||||
|
currency: str,
|
||||||
|
) -> tuple[Decimal, dict[str, str] | None]:
|
||||||
|
if currency.upper() == "XMR":
|
||||||
|
return amount, None
|
||||||
|
try:
|
||||||
|
quote = get_xmr_rate(currency)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Fiat quote service unavailable",
|
||||||
|
) from exc
|
||||||
|
amount_xmr = (Decimal(amount) / quote.rate).quantize(
|
||||||
|
Decimal("0.000000000001"), rounding=ROUND_DOWN
|
||||||
|
)
|
||||||
|
quote_payload = {
|
||||||
|
"fiat_amount": str(amount),
|
||||||
|
"fiat_currency": quote.currency,
|
||||||
|
"rate": str(quote.rate),
|
||||||
|
"source": quote.source,
|
||||||
|
"quoted_at": quote.quoted_at.isoformat(),
|
||||||
|
}
|
||||||
|
return amount_xmr, quote_payload
|
||||||
|
|
||||||
|
|
||||||
|
def _btcpay_status(invoice: Invoice) -> tuple[str, str]:
|
||||||
|
additional_status = _btcpay_additional_status(invoice)
|
||||||
|
if invoice.status == "payment_detected":
|
||||||
|
return "Processing", additional_status
|
||||||
|
if invoice.status == "confirmed":
|
||||||
|
return "Settled", additional_status
|
||||||
|
if invoice.status == "expired":
|
||||||
|
return "Expired", additional_status
|
||||||
|
if invoice.status == "invalid":
|
||||||
|
return "Invalid", additional_status
|
||||||
|
return "New", additional_status
|
||||||
|
|
||||||
|
|
||||||
|
def _btcpay_additional_status(invoice: Invoice) -> str:
|
||||||
|
if invoice.status == "invalid":
|
||||||
|
return "Marked"
|
||||||
|
paid_atomic = invoice.total_paid_atomic or 0
|
||||||
|
required_atomic = _xmr_to_atomic(invoice.amount_xmr)
|
||||||
|
if paid_atomic > required_atomic:
|
||||||
|
return "PaidOver"
|
||||||
|
if 0 < paid_atomic < required_atomic:
|
||||||
|
return "PaidPartial"
|
||||||
|
if _after_expiration(invoice):
|
||||||
|
return "PaidLate"
|
||||||
|
return "None"
|
||||||
|
|
||||||
|
|
||||||
|
def _after_expiration(invoice: Invoice) -> bool:
|
||||||
|
expires_at = invoice.expires_at
|
||||||
|
detected_at = invoice.detected_at
|
||||||
|
if expires_at is None or detected_at is None:
|
||||||
|
return False
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if detected_at.tzinfo is None:
|
||||||
|
detected_at = detected_at.replace(tzinfo=timezone.utc)
|
||||||
|
return detected_at > expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def _xmr_to_atomic(amount: Decimal) -> int:
|
||||||
|
quantized = (Decimal(amount) * Decimal("1000000000000")).to_integral_value(
|
||||||
|
rounding=ROUND_DOWN
|
||||||
|
)
|
||||||
|
return int(quantized)
|
||||||
|
|
||||||
|
|
||||||
|
def _btcpay_amount_currency(invoice: Invoice) -> tuple[str, str, dict[str, Any]]:
|
||||||
|
metadata = invoice.metadata_json or {}
|
||||||
|
btcpay_data = metadata.get("btcpay") if isinstance(metadata, dict) else None
|
||||||
|
if isinstance(btcpay_data, dict):
|
||||||
|
amount = btcpay_data.get("amount")
|
||||||
|
currency = btcpay_data.get("currency")
|
||||||
|
if isinstance(amount, str) and isinstance(currency, str):
|
||||||
|
return amount, currency, btcpay_data
|
||||||
|
return format_xmr_amount(invoice.amount_xmr), "XMR", {}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_xmr_fixed(value: Decimal) -> str:
|
||||||
|
return f"{value.quantize(Decimal('0.000000000001')):.12f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_to_xmr(value: int) -> Decimal:
|
||||||
|
return (Decimal(value) / Decimal("1000000000000")).quantize(
|
||||||
|
Decimal("0.000000000001"), rounding=ROUND_DOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores")
|
||||||
|
def list_stores(user: User = Depends(_require_btcpay_user)):
|
||||||
|
return [_store_payload(user)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}")
|
||||||
|
def get_store(store_id: str, user: User = Depends(_require_btcpay_user)):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
return _store_payload(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}/payment-methods")
|
||||||
|
def list_payment_methods(store_id: str, user: User = Depends(_require_btcpay_user)):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"paymentMethodId": BTCPAY_PAYMENT_METHOD,
|
||||||
|
"paymentMethod": BTCPAY_PAYMENT_METHOD,
|
||||||
|
"enabled": True,
|
||||||
|
"cryptoCode": "XMR",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/stores/{store_id}/invoices", status_code=200)
|
||||||
|
def create_invoice(
|
||||||
|
store_id: str,
|
||||||
|
payload: BtcpayInvoiceCreate,
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
checkout = payload.checkout
|
||||||
|
payment_methods = (
|
||||||
|
[_normalize_btcpay_payment_method(method) for method in checkout.paymentMethods]
|
||||||
|
if checkout and checkout.paymentMethods
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if payment_methods and BTCPAY_PAYMENT_METHOD not in payment_methods:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Unsupported payment method",
|
||||||
|
)
|
||||||
|
amount_xmr, quote_payload = _resolve_btcpay_amount(payload.amount, payload.currency)
|
||||||
|
expires_at = None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expiration_minutes = None
|
||||||
|
monitoring_minutes = None
|
||||||
|
if checkout and checkout.expirationMinutes:
|
||||||
|
expiration_minutes = int(checkout.expirationMinutes)
|
||||||
|
if expiration_minutes:
|
||||||
|
expires_at = now + timedelta(minutes=expiration_minutes)
|
||||||
|
else:
|
||||||
|
expires_at = now + timedelta(hours=INVOICE_DEFAULT_EXPIRY_HOURS)
|
||||||
|
if checkout and checkout.monitoringMinutes is not None:
|
||||||
|
monitoring_minutes = int(checkout.monitoringMinutes)
|
||||||
|
metadata: dict[str, Any] = dict(payload.metadata or {})
|
||||||
|
btcpay_data = metadata.get("btcpay") if isinstance(metadata.get("btcpay"), dict) else {}
|
||||||
|
btcpay_data = dict(btcpay_data)
|
||||||
|
btcpay_data.update(
|
||||||
|
{
|
||||||
|
"amount": str(payload.amount),
|
||||||
|
"currency": payload.currency,
|
||||||
|
"checkout": checkout.model_dump(mode="json") if checkout else None,
|
||||||
|
"expiration_minutes": expiration_minutes,
|
||||||
|
"monitoring_minutes": monitoring_minutes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata["btcpay"] = btcpay_data
|
||||||
|
if quote_payload and "quote" not in metadata:
|
||||||
|
metadata["quote"] = quote_payload
|
||||||
|
qr = metadata.get("qr")
|
||||||
|
if not isinstance(qr, dict):
|
||||||
|
qr = {"logo": user.default_qr_logo}
|
||||||
|
if user.default_qr_logo == "custom" and user.default_qr_logo_data_url:
|
||||||
|
qr["logo_data_url"] = user.default_qr_logo_data_url
|
||||||
|
metadata["qr"] = qr
|
||||||
|
address, subaddress_index = create_subaddress_for_user(db, user=user)
|
||||||
|
invoice = Invoice(
|
||||||
|
user_id=user.id,
|
||||||
|
wallet_address=user.payment_address,
|
||||||
|
address=address,
|
||||||
|
subaddress_index=subaddress_index,
|
||||||
|
amount_xmr=amount_xmr,
|
||||||
|
status="pending",
|
||||||
|
confirmation_target=user.default_confirmation_target,
|
||||||
|
total_paid_atomic=0,
|
||||||
|
metadata_json=metadata,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(invoice)
|
||||||
|
try:
|
||||||
|
ensure_invoice_qr_png(
|
||||||
|
invoice=invoice,
|
||||||
|
storage_dir=QR_STORAGE_DIR,
|
||||||
|
settings=resolve_qr_settings(invoice),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
dispatch_webhooks(db, str(user.id), "invoice.created", invoice)
|
||||||
|
status_name, additional_status = _btcpay_status(invoice)
|
||||||
|
amount, currency, _ = _btcpay_amount_currency(invoice)
|
||||||
|
expiration_time = _epoch_seconds(invoice.expires_at)
|
||||||
|
monitoring_time = expiration_time
|
||||||
|
if monitoring_minutes is None:
|
||||||
|
monitoring_minutes = 60
|
||||||
|
monitoring_time = expiration_time + int(monitoring_minutes * 60)
|
||||||
|
return {
|
||||||
|
"id": str(invoice.id),
|
||||||
|
"amount": amount,
|
||||||
|
"currency": currency,
|
||||||
|
"type": "Standard",
|
||||||
|
"checkoutLink": _invoice_checkout_link(invoice, request),
|
||||||
|
"createdTime": _epoch_seconds(invoice.created_at),
|
||||||
|
"expirationTime": expiration_time,
|
||||||
|
"monitoringTime": monitoring_time,
|
||||||
|
"archived": False,
|
||||||
|
"status": status_name,
|
||||||
|
"additionalStatus": additional_status,
|
||||||
|
"availableStatusesForManualMarking": ["Invalid"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}/invoices/{invoice_id}")
|
||||||
|
def get_invoice(
|
||||||
|
store_id: str,
|
||||||
|
invoice_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
try:
|
||||||
|
invoice_uuid = uuid.UUID(invoice_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
invoice = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
status_name, additional_status = _btcpay_status(invoice)
|
||||||
|
amount, currency, btcpay_data = _btcpay_amount_currency(invoice)
|
||||||
|
expiration_time = _epoch_seconds(invoice.expires_at)
|
||||||
|
monitoring_minutes = (
|
||||||
|
btcpay_data.get("monitoring_minutes")
|
||||||
|
if isinstance(btcpay_data, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if monitoring_minutes is None:
|
||||||
|
monitoring_minutes = 60
|
||||||
|
monitoring_time = expiration_time + int(int(monitoring_minutes) * 60)
|
||||||
|
return {
|
||||||
|
"id": str(invoice.id),
|
||||||
|
"amount": amount,
|
||||||
|
"currency": currency,
|
||||||
|
"type": "Standard",
|
||||||
|
"checkoutLink": _invoice_checkout_link(invoice, request),
|
||||||
|
"createdTime": _epoch_seconds(invoice.created_at),
|
||||||
|
"expirationTime": expiration_time,
|
||||||
|
"monitoringTime": monitoring_time,
|
||||||
|
"archived": invoice.archived_at is not None,
|
||||||
|
"status": status_name,
|
||||||
|
"additionalStatus": additional_status,
|
||||||
|
"availableStatusesForManualMarking": ["Invalid"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}/invoices/{invoice_id}/payment-methods")
|
||||||
|
def get_invoice_payment_methods(
|
||||||
|
store_id: str,
|
||||||
|
invoice_id: str,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
try:
|
||||||
|
invoice_uuid = uuid.UUID(invoice_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
invoice = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
transfers = (
|
||||||
|
db.query(InvoiceTransfer)
|
||||||
|
.filter(InvoiceTransfer.invoice_id == invoice.id)
|
||||||
|
.order_by(InvoiceTransfer.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_atomic = sum(
|
||||||
|
transfer.amount_atomic for transfer in transfers if transfer.amount_atomic > 0
|
||||||
|
)
|
||||||
|
total_paid = _atomic_to_xmr(total_atomic)
|
||||||
|
due = max(Decimal("0"), invoice.amount_xmr - total_paid)
|
||||||
|
metadata = invoice.metadata_json or {}
|
||||||
|
quote = metadata.get("quote") if isinstance(metadata, dict) else None
|
||||||
|
rate_value = "0"
|
||||||
|
if isinstance(quote, dict) and "rate" in quote:
|
||||||
|
rate_value = str(quote.get("rate", "0"))
|
||||||
|
payments = []
|
||||||
|
created_fallback = _epoch_seconds(invoice.created_at)
|
||||||
|
for transfer in transfers:
|
||||||
|
if transfer.amount_atomic <= 0:
|
||||||
|
continue
|
||||||
|
confirmations = transfer.confirmations
|
||||||
|
status_label = (
|
||||||
|
"confirmed"
|
||||||
|
if confirmations >= max(0, invoice.confirmation_target)
|
||||||
|
else "pending"
|
||||||
|
)
|
||||||
|
payments.append(
|
||||||
|
{
|
||||||
|
"id": f"{transfer.txid}-0",
|
||||||
|
"value": _format_xmr_fixed(_atomic_to_xmr(transfer.amount_atomic)),
|
||||||
|
"fee": "0",
|
||||||
|
"destination": transfer.address or invoice.address,
|
||||||
|
"status": status_label,
|
||||||
|
"receivedDate": transfer.timestamp or created_fallback,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"paymentMethodId": BTCPAY_PAYMENT_METHOD,
|
||||||
|
"paymentMethod": BTCPAY_PAYMENT_METHOD,
|
||||||
|
"cryptoCode": "XMR",
|
||||||
|
"destination": invoice.address,
|
||||||
|
"rate": rate_value,
|
||||||
|
"amount": _format_xmr_fixed(invoice.amount_xmr),
|
||||||
|
"due": _format_xmr_fixed(due),
|
||||||
|
"totalPaid": _format_xmr_fixed(total_paid),
|
||||||
|
"paymentMethodPaid": _format_xmr_fixed(total_paid),
|
||||||
|
"networkFee": "0",
|
||||||
|
"payments": payments,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/stores/{store_id}/invoices/{invoice_id}/status", status_code=200)
|
||||||
|
def mark_invoice_status(
|
||||||
|
store_id: str,
|
||||||
|
invoice_id: str,
|
||||||
|
payload: BtcpayInvoiceStatusUpdate,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
if payload.status.strip().lower() != "invalid":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Unsupported status",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
invoice_uuid = uuid.UUID(invoice_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
invoice = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
if invoice.status == "confirmed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Confirmed invoices cannot be marked invalid",
|
||||||
|
)
|
||||||
|
invoice.status = "invalid"
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(invoice)
|
||||||
|
dispatch_btcpay_webhooks(
|
||||||
|
db,
|
||||||
|
str(user.id),
|
||||||
|
"InvoiceInvalid",
|
||||||
|
invoice,
|
||||||
|
manually_marked=True,
|
||||||
|
)
|
||||||
|
return {"status": "Invalid"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/stores/{store_id}/webhooks", status_code=200)
|
||||||
|
def create_webhook(
|
||||||
|
store_id: str,
|
||||||
|
payload: BtcpayWebhookCreate,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
_validate_webhook_events(payload.authorizedEvents.specificEvents)
|
||||||
|
secret = generate_webhook_secret()
|
||||||
|
webhook = BtcpayWebhook(
|
||||||
|
user_id=user.id,
|
||||||
|
url=str(payload.url),
|
||||||
|
enabled=payload.enabled,
|
||||||
|
automatic_redelivery=payload.automaticRedelivery,
|
||||||
|
authorized_events=payload.authorizedEvents.model_dump(),
|
||||||
|
secret_encrypted=encrypt_secret(secret),
|
||||||
|
)
|
||||||
|
db.add(webhook)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(webhook)
|
||||||
|
return _webhook_response(webhook, include_secret=True, secret=secret)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}/webhooks")
|
||||||
|
def list_webhooks(
|
||||||
|
store_id: str,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
hooks = (
|
||||||
|
db.query(BtcpayWebhook)
|
||||||
|
.filter(BtcpayWebhook.user_id == user.id)
|
||||||
|
.order_by(BtcpayWebhook.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_webhook_response(hook) for hook in hooks]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}/webhooks/{webhook_id}")
|
||||||
|
def get_webhook(
|
||||||
|
store_id: str,
|
||||||
|
webhook_id: str,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
hook = _get_webhook(db, webhook_id, user)
|
||||||
|
return _webhook_response(hook)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/stores/{store_id}/webhooks/{webhook_id}")
|
||||||
|
def update_webhook(
|
||||||
|
store_id: str,
|
||||||
|
webhook_id: str,
|
||||||
|
payload: BtcpayWebhookUpdate,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
hook = _get_webhook(db, webhook_id, user)
|
||||||
|
if payload.authorizedEvents and payload.authorizedEvents.specificEvents:
|
||||||
|
_validate_webhook_events(payload.authorizedEvents.specificEvents)
|
||||||
|
if payload.enabled is not None:
|
||||||
|
hook.enabled = payload.enabled
|
||||||
|
if payload.automaticRedelivery is not None:
|
||||||
|
hook.automatic_redelivery = payload.automaticRedelivery
|
||||||
|
if payload.url is not None:
|
||||||
|
hook.url = str(payload.url)
|
||||||
|
if payload.authorizedEvents is not None:
|
||||||
|
hook.authorized_events = payload.authorizedEvents.model_dump()
|
||||||
|
db.add(hook)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(hook)
|
||||||
|
return _webhook_response(hook)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/stores/{store_id}/webhooks/{webhook_id}",
|
||||||
|
status_code=204,
|
||||||
|
)
|
||||||
|
def delete_webhook(
|
||||||
|
store_id: str,
|
||||||
|
webhook_id: str,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
hook = _get_webhook(db, webhook_id, user)
|
||||||
|
db.delete(hook)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/server/info")
|
||||||
|
def server_info():
|
||||||
|
return {
|
||||||
|
"version": "1.7.5",
|
||||||
|
"onion": None,
|
||||||
|
"fullySynched": True,
|
||||||
|
"supportedPaymentMethods": [BTCPAY_PAYMENT_METHOD],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/api-keys/current")
|
||||||
|
def api_key_current(
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
):
|
||||||
|
store_id = str(user.id)
|
||||||
|
return {
|
||||||
|
"apiKey": decrypt_api_key(user.api_key_encrypted),
|
||||||
|
"label": "XMR Checkout",
|
||||||
|
"permissions": [
|
||||||
|
f"btcpay.store.canviewinvoices:{store_id}",
|
||||||
|
f"btcpay.store.cancreateinvoice:{store_id}",
|
||||||
|
f"btcpay.store.canviewstoresettings:{store_id}",
|
||||||
|
f"btcpay.store.canmodifyinvoices:{store_id}",
|
||||||
|
f"btcpay.store.webhooks.canmodifywebhooks:{store_id}",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_webhook(db: Session, webhook_id: str, user: User) -> BtcpayWebhook:
|
||||||
|
try:
|
||||||
|
webhook_uuid = uuid.UUID(webhook_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Webhook not found",
|
||||||
|
)
|
||||||
|
hook = (
|
||||||
|
db.query(BtcpayWebhook)
|
||||||
|
.filter(BtcpayWebhook.id == webhook_uuid, BtcpayWebhook.user_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if hook is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Webhook not found",
|
||||||
|
)
|
||||||
|
return hook
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_webhook_events(events: list[str] | None) -> None:
|
||||||
|
if not events:
|
||||||
|
return
|
||||||
|
for event in events:
|
||||||
|
if event not in BTCPAY_WEBHOOK_EVENTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Webhook event is not supported",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _webhook_response(
|
||||||
|
hook: BtcpayWebhook,
|
||||||
|
*,
|
||||||
|
include_secret: bool = False,
|
||||||
|
secret: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"id": str(hook.id),
|
||||||
|
"enabled": hook.enabled,
|
||||||
|
"automaticRedelivery": hook.automatic_redelivery,
|
||||||
|
"url": hook.url,
|
||||||
|
"authorizedEvents": hook.authorized_events,
|
||||||
|
}
|
||||||
|
if include_secret and secret:
|
||||||
|
payload["secret"] = secret
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
AnyUrl,
|
||||||
|
BaseModel,
|
||||||
|
Field,
|
||||||
|
conint,
|
||||||
|
field_validator,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayCheckout(BaseModel):
|
||||||
|
redirectURL: AnyUrl | None = None
|
||||||
|
speedPolicy: str | None = None
|
||||||
|
paymentMethods: list[str] | None = None
|
||||||
|
expirationMinutes: conint(ge=1, le=10080) | None = None
|
||||||
|
monitoringMinutes: conint(ge=0, le=10080) | None = None
|
||||||
|
paymentTolerance: Decimal | None = None
|
||||||
|
redirectAutomatically: bool | None = None
|
||||||
|
defaultLanguage: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayInvoiceCreate(BaseModel):
|
||||||
|
amount: Decimal = Field(gt=0)
|
||||||
|
currency: str = Field(min_length=1)
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
checkout: BtcpayCheckout | None = None
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_currency(cls, value: str) -> str:
|
||||||
|
return value.strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayWebhookEvents(BaseModel):
|
||||||
|
everything: bool = False
|
||||||
|
specificEvents: list[str] | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_selection(self) -> "BtcpayWebhookEvents":
|
||||||
|
if not self.everything and not self.specificEvents:
|
||||||
|
raise ValueError("Select at least one event")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayWebhookCreate(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
automaticRedelivery: bool = True
|
||||||
|
url: AnyUrl
|
||||||
|
authorizedEvents: BtcpayWebhookEvents
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayWebhookUpdate(BaseModel):
|
||||||
|
enabled: bool | None = None
|
||||||
|
automaticRedelivery: bool | None = None
|
||||||
|
url: AnyUrl | None = None
|
||||||
|
authorizedEvents: BtcpayWebhookEvents | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayInvoiceStatusUpdate(BaseModel):
|
||||||
|
status: str = Field(min_length=1)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests import RequestException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import BtcpayWebhook, Invoice
|
||||||
|
from .security import decrypt_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_btcpay_webhooks(
|
||||||
|
db: Session,
|
||||||
|
user_id: str,
|
||||||
|
event_type: str,
|
||||||
|
invoice: Invoice,
|
||||||
|
*,
|
||||||
|
manually_marked: bool = False,
|
||||||
|
) -> None:
|
||||||
|
hooks = (
|
||||||
|
db.query(BtcpayWebhook)
|
||||||
|
.filter(
|
||||||
|
BtcpayWebhook.user_id == user_id,
|
||||||
|
BtcpayWebhook.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not hooks:
|
||||||
|
return
|
||||||
|
payload = _build_payload(
|
||||||
|
event_type=event_type,
|
||||||
|
user_id=user_id,
|
||||||
|
invoice=invoice,
|
||||||
|
manually_marked=manually_marked,
|
||||||
|
)
|
||||||
|
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
for hook in hooks:
|
||||||
|
if not _event_allowed(hook.authorized_events, event_type):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
secret = decrypt_secret(hook.secret_encrypted)
|
||||||
|
signature = _sign_payload(body, secret)
|
||||||
|
headers = {
|
||||||
|
"BTCPay-Sig": f"sha256={signature}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
requests.post(hook.url, data=body, headers=headers, timeout=5)
|
||||||
|
except RequestException as exc:
|
||||||
|
logger.warning(
|
||||||
|
"BTCPay webhook delivery failed",
|
||||||
|
extra={"webhook_id": str(hook.id), "event": event_type},
|
||||||
|
)
|
||||||
|
logger.debug("BTCPay webhook delivery error: %s", exc)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"BTCPay webhook dispatch failed",
|
||||||
|
extra={"webhook_id": str(hook.id), "event": event_type, "error": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_allowed(authorized_events: object, event_type: str) -> bool:
|
||||||
|
if not isinstance(authorized_events, dict):
|
||||||
|
return False
|
||||||
|
if authorized_events.get("everything") is True:
|
||||||
|
return True
|
||||||
|
specific = authorized_events.get("specificEvents") or []
|
||||||
|
return event_type in specific
|
||||||
|
|
||||||
|
|
||||||
|
def _build_payload(
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
user_id: str,
|
||||||
|
invoice: Invoice,
|
||||||
|
manually_marked: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"type": event_type,
|
||||||
|
"timestamp": int(time.time()),
|
||||||
|
"storeId": user_id,
|
||||||
|
"invoiceId": str(invoice.id),
|
||||||
|
"manuallyMarked": manually_marked,
|
||||||
|
"overPaid": False,
|
||||||
|
"partiallyPaid": False,
|
||||||
|
"afterExpiration": _after_expiration(invoice),
|
||||||
|
"metadata": invoice.metadata_json or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _after_expiration(invoice: Invoice) -> bool:
|
||||||
|
expires_at = invoice.expires_at
|
||||||
|
detected_at = invoice.detected_at
|
||||||
|
if expires_at is None or detected_at is None:
|
||||||
|
return False
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if detected_at.tzinfo is None:
|
||||||
|
detected_at = detected_at.replace(tzinfo=timezone.utc)
|
||||||
|
return detected_at > expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_payload(body: bytes, secret: str) -> str:
|
||||||
|
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bool_env(name: str, default: bool = False) -> bool:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_env(name: str, default: str | None = None) -> str:
|
||||||
|
value = os.getenv(name, default)
|
||||||
|
if value is None or value == "":
|
||||||
|
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
DATABASE_URL = _get_env("DATABASE_URL")
|
||||||
|
|
||||||
|
API_KEYS = [
|
||||||
|
key.strip()
|
||||||
|
for key in os.getenv("API_KEYS", "").split(",")
|
||||||
|
if key.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
API_KEY_ENCRYPTION_KEY = _get_env("API_KEY_ENCRYPTION_KEY")
|
||||||
|
INVOICE_RECONCILE_INTERVAL_SECONDS = int(os.getenv("INVOICE_RECONCILE_INTERVAL_SECONDS", "30"))
|
||||||
|
INVOICE_DEFAULT_EXPIRY_HOURS = int(os.getenv("INVOICE_DEFAULT_EXPIRY_HOURS", "24"))
|
||||||
|
LATE_PAYMENT_LOOKBACK_HOURS = int(os.getenv("LATE_PAYMENT_LOOKBACK_HOURS", "48"))
|
||||||
|
DONATION_EXPIRY_MINUTES = int(os.getenv("DONATION_EXPIRY_MINUTES", "30"))
|
||||||
|
DONATION_ACTIVE_INVOICE_LIMIT = int(os.getenv("DONATION_ACTIVE_INVOICE_LIMIT", "25"))
|
||||||
|
COINGECKO_API_KEY = os.getenv("COINGECKO_API_KEY", "")
|
||||||
|
DONATIONS_ENABLED = _get_bool_env("DONATIONS_ENABLED", False)
|
||||||
|
|
||||||
|
MONERO_WALLET_RPC_URLS = [
|
||||||
|
url.strip()
|
||||||
|
for url in os.getenv("MONERO_WALLET_RPC_URLS", "").split(",")
|
||||||
|
if url.strip()
|
||||||
|
]
|
||||||
|
MONERO_WALLET_RPC_USER = os.getenv("MONERO_WALLET_RPC_USER", "")
|
||||||
|
MONERO_WALLET_RPC_PASSWORD = os.getenv("MONERO_WALLET_RPC_PASSWORD", "")
|
||||||
|
MONERO_WALLET_RPC_WALLET_PASSWORD = os.getenv("MONERO_WALLET_RPC_WALLET_PASSWORD", "")
|
||||||
|
MONERO_DAEMON_URL = os.getenv("MONERO_DAEMON_URL")
|
||||||
|
MONERO_WALLET_RPC_WALLET_DIR = os.getenv("MONERO_WALLET_RPC_WALLET_DIR", "")
|
||||||
|
FOUNDER_PAYMENT_ADDRESS = os.getenv("FOUNDER_PAYMENT_ADDRESS", "")
|
||||||
|
FOUNDER_VIEW_KEY = os.getenv("FOUNDER_VIEW_KEY", "")
|
||||||
|
QR_STORAGE_DIR = os.getenv("QR_STORAGE_DIR", "/qr")
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
|
||||||
|
from .config import DATABASE_URL
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def format_xmr_amount(value: Decimal) -> str:
|
||||||
|
normalized = value.normalize()
|
||||||
|
formatted = format(normalized, "f")
|
||||||
|
if "." in formatted:
|
||||||
|
formatted = formatted.rstrip("0").rstrip(".")
|
||||||
|
return formatted or "0"
|
||||||
+242
@@ -0,0 +1,242 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from .db import Base, engine
|
||||||
|
from .config import DONATIONS_ENABLED
|
||||||
|
from .btcpay_routes import router as btcpay_router
|
||||||
|
from .routes import router
|
||||||
|
|
||||||
|
app = FastAPI(title="xmrcheckout.com API", version="0.1.0")
|
||||||
|
|
||||||
|
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper())
|
||||||
|
|
||||||
|
|
||||||
|
def custom_openapi():
|
||||||
|
if app.openapi_schema:
|
||||||
|
return app.openapi_schema
|
||||||
|
openapi_schema = get_openapi(
|
||||||
|
title=app.title,
|
||||||
|
version=app.version,
|
||||||
|
description=app.description,
|
||||||
|
routes=app.routes,
|
||||||
|
)
|
||||||
|
openapi_schema.get("paths", {}).pop("/api/core/donations", None)
|
||||||
|
if not DONATIONS_ENABLED:
|
||||||
|
openapi_schema.get("paths", {}).pop("/api/core/public/donation/{invoice_id}", None)
|
||||||
|
openapi_schema.get("paths", {}).pop("/api/core/auth/validate", None)
|
||||||
|
openapi_schema.get("paths", {}).pop("/api/core/profile", None)
|
||||||
|
app.openapi_schema = openapi_schema
|
||||||
|
return app.openapi_schema
|
||||||
|
|
||||||
|
|
||||||
|
app.openapi = custom_openapi
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup():
|
||||||
|
lock_acquired = False
|
||||||
|
lock_id = 894221741
|
||||||
|
with engine.begin() as connection:
|
||||||
|
try:
|
||||||
|
connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": lock_id})
|
||||||
|
lock_acquired = True
|
||||||
|
except Exception:
|
||||||
|
lock_acquired = False
|
||||||
|
try:
|
||||||
|
Base.metadata.create_all(bind=connection)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS user_id UUID")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS wallet_address VARCHAR")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS subaddress_index INTEGER")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE invoices "
|
||||||
|
"ADD COLUMN IF NOT EXISTS confirmations INTEGER DEFAULT 0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE invoices "
|
||||||
|
"ADD COLUMN IF NOT EXISTS total_paid_atomic BIGINT"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE invoices "
|
||||||
|
"ADD COLUMN IF NOT EXISTS paid_after_expiry BOOLEAN DEFAULT false"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE invoices "
|
||||||
|
"ADD COLUMN IF NOT EXISTS paid_after_expiry_at TIMESTAMP WITH TIME ZONE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE invoices "
|
||||||
|
"ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("UPDATE invoices SET confirmations = 0 WHERE confirmations IS NULL")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("CREATE INDEX IF NOT EXISTS ix_invoices_user_id ON invoices (user_id)")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_invoices_wallet_address ON invoices (wallet_address)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(text("ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS user_id UUID"))
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS event_urls JSON")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE webhooks ALTER COLUMN url DROP NOT NULL")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("CREATE INDEX IF NOT EXISTS ix_webhooks_user_id ON webhooks (user_id)")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_webhook_deliveries_user_id "
|
||||||
|
"ON webhook_deliveries (user_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_webhook_deliveries_webhook_id "
|
||||||
|
"ON webhook_deliveries (webhook_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS invoice_id UUID"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS invoice_address VARCHAR"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS invoice_subaddress_index INTEGER"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS invoice_amount_xmr NUMERIC(18, 12)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS invoice_status VARCHAR"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS payload JSON"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS payment_address VARCHAR")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS view_key_encrypted VARCHAR")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS webhook_secret_encrypted VARCHAR"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS next_subaddress_index INTEGER DEFAULT 1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS subaddress_start_index INTEGER DEFAULT 0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS default_confirmation_target INTEGER DEFAULT 10"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS default_qr_logo VARCHAR DEFAULT 'monero'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS default_qr_logo_data_url VARCHAR"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS btcpay_checkout_style VARCHAR DEFAULT 'standard'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET btcpay_checkout_style = 'standard' "
|
||||||
|
"WHERE btcpay_checkout_style IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET default_confirmation_target = 10 "
|
||||||
|
"WHERE default_confirmation_target IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET default_qr_logo = 'monero' "
|
||||||
|
"WHERE default_qr_logo IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("CREATE INDEX IF NOT EXISTS ix_users_payment_address ON users (payment_address)")
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if lock_acquired:
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT pg_advisory_unlock(:lock_id)"), {"lock_id": lock_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(router)
|
||||||
|
app.include_router(btcpay_router)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY, UUID
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from .db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(Base):
|
||||||
|
__tablename__ = "invoices"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
|
wallet_address = Column(String, nullable=True, index=True)
|
||||||
|
address = Column(String, nullable=False)
|
||||||
|
subaddress_index = Column(Integer, nullable=True)
|
||||||
|
amount_xmr = Column(Numeric(18, 12), nullable=False)
|
||||||
|
status = Column(String, nullable=False, index=True)
|
||||||
|
confirmation_target = Column(Integer, nullable=False)
|
||||||
|
confirmations = Column(Integer, nullable=False, server_default="0")
|
||||||
|
total_paid_atomic = Column(BigInteger, nullable=True)
|
||||||
|
paid_after_expiry = Column(Boolean, nullable=False, server_default="false")
|
||||||
|
paid_after_expiry_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
metadata_json = Column("metadata", JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
archived_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
detected_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
confirmed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceTransfer(Base):
|
||||||
|
__tablename__ = "invoice_transfers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"invoice_id",
|
||||||
|
"txid",
|
||||||
|
name="uq_invoice_transfers_invoice_txid",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
invoice_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
txid = Column(String, nullable=False)
|
||||||
|
amount_atomic = Column(BigInteger, nullable=False)
|
||||||
|
confirmations = Column(Integer, nullable=False, server_default="0")
|
||||||
|
timestamp = Column(BigInteger, nullable=True)
|
||||||
|
address = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Webhook(Base):
|
||||||
|
__tablename__ = "webhooks"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
|
url = Column(String, nullable=True)
|
||||||
|
events = Column(ARRAY(String), nullable=False)
|
||||||
|
event_urls = Column(JSON, nullable=True)
|
||||||
|
active = Column(Boolean, nullable=False, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayWebhook(Base):
|
||||||
|
__tablename__ = "btcpay_webhooks"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
url = Column(String, nullable=False)
|
||||||
|
enabled = Column(Boolean, nullable=False, default=True)
|
||||||
|
automatic_redelivery = Column(Boolean, nullable=False, default=True)
|
||||||
|
authorized_events = Column(JSON, nullable=False)
|
||||||
|
secret_encrypted = Column(String, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookDelivery(Base):
|
||||||
|
__tablename__ = "webhook_deliveries"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
webhook_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
|
event = Column(String, nullable=False)
|
||||||
|
url = Column(String, nullable=False)
|
||||||
|
invoice_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
invoice_address = Column(String, nullable=True)
|
||||||
|
invoice_subaddress_index = Column(Integer, nullable=True)
|
||||||
|
invoice_amount_xmr = Column(Numeric(18, 12), nullable=True)
|
||||||
|
invoice_status = Column(String, nullable=True)
|
||||||
|
payload_json = Column("payload", JSON, nullable=True)
|
||||||
|
http_status = Column(Integer, nullable=True)
|
||||||
|
error_message = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
payment_address = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
view_key_encrypted = Column(String, nullable=False)
|
||||||
|
api_key_hash = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
api_key_encrypted = Column(String, nullable=False)
|
||||||
|
webhook_secret_encrypted = Column(String, nullable=True)
|
||||||
|
next_subaddress_index = Column(Integer, nullable=False, server_default="1")
|
||||||
|
subaddress_start_index = Column(Integer, nullable=False, server_default="0")
|
||||||
|
default_confirmation_target = Column(Integer, nullable=False, server_default="10")
|
||||||
|
default_qr_logo = Column(String, nullable=False, server_default="monero")
|
||||||
|
default_qr_logo_data_url = Column(String, nullable=True)
|
||||||
|
btcpay_checkout_style = Column(String, nullable=False, server_default="standard")
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileHistory(Base):
|
||||||
|
__tablename__ = "profile_history"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
field_name = Column(String, nullable=False)
|
||||||
|
old_value = Column(String, nullable=True)
|
||||||
|
new_value = Column(String, nullable=True)
|
||||||
|
value_encrypted = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from monero.backends.jsonrpc import JSONRPCWallet
|
||||||
|
from monero.backends.jsonrpc.exceptions import RPCError
|
||||||
|
import requests
|
||||||
|
from requests import RequestException
|
||||||
|
from requests.auth import HTTPDigestAuth
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
MONERO_DAEMON_URL,
|
||||||
|
MONERO_WALLET_RPC_PASSWORD,
|
||||||
|
MONERO_WALLET_RPC_URLS,
|
||||||
|
MONERO_WALLET_RPC_USER,
|
||||||
|
MONERO_WALLET_RPC_WALLET_PASSWORD,
|
||||||
|
MONERO_WALLET_RPC_WALLET_DIR,
|
||||||
|
)
|
||||||
|
from .models import User
|
||||||
|
from .security import decrypt_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_daemon_address(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
parsed = urlparse(value)
|
||||||
|
if parsed.scheme:
|
||||||
|
address = parsed.netloc
|
||||||
|
else:
|
||||||
|
address = value.split("/", 1)[0]
|
||||||
|
return address.rstrip("/") if address else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_daemon_url(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
parsed = urlparse(value)
|
||||||
|
if parsed.scheme:
|
||||||
|
return value.rstrip("/")
|
||||||
|
return f"http://{value.rstrip('/')}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubaddressResult:
|
||||||
|
address: str
|
||||||
|
address_index: int | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TransferDetail:
|
||||||
|
txid: str
|
||||||
|
amount_atomic: int
|
||||||
|
confirmations: int
|
||||||
|
timestamp: int | None
|
||||||
|
address: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WalletBackend:
|
||||||
|
client: JSONRPCWallet
|
||||||
|
url: str
|
||||||
|
current_wallet: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MoneroWalletService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
urls = MONERO_WALLET_RPC_URLS
|
||||||
|
if not urls:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Monero wallet RPC is not configured",
|
||||||
|
)
|
||||||
|
self._backends: list[WalletBackend] = []
|
||||||
|
for url in urls:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or "127.0.0.1"
|
||||||
|
port = parsed.port or 18082
|
||||||
|
client = JSONRPCWallet(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
user=MONERO_WALLET_RPC_USER or None,
|
||||||
|
password=MONERO_WALLET_RPC_PASSWORD or None,
|
||||||
|
)
|
||||||
|
if MONERO_WALLET_RPC_USER and MONERO_WALLET_RPC_PASSWORD:
|
||||||
|
# monero-wallet-rpc uses digest auth when rpc-login is set.
|
||||||
|
client.session.auth = HTTPDigestAuth(
|
||||||
|
MONERO_WALLET_RPC_USER, MONERO_WALLET_RPC_PASSWORD
|
||||||
|
)
|
||||||
|
self._backends.append(WalletBackend(client=client, url=url))
|
||||||
|
self._daemon_url = _normalize_daemon_url(MONERO_DAEMON_URL)
|
||||||
|
self._daemon_address = _normalize_daemon_address(MONERO_DAEMON_URL)
|
||||||
|
self._wallet_dir = MONERO_WALLET_RPC_WALLET_DIR.strip()
|
||||||
|
|
||||||
|
def get_status(self) -> dict[str, str]:
|
||||||
|
for backend in self._backends:
|
||||||
|
try:
|
||||||
|
backend.client.raw_request("get_version")
|
||||||
|
except Exception:
|
||||||
|
return {"wallet_rpc": "unreachable"}
|
||||||
|
|
||||||
|
if self._daemon_address:
|
||||||
|
try:
|
||||||
|
self._backends[0].client.raw_request(
|
||||||
|
"set_daemon",
|
||||||
|
{"address": self._daemon_address},
|
||||||
|
)
|
||||||
|
except RPCError as exc:
|
||||||
|
message = str(exc)
|
||||||
|
if "no connection to daemon" in message or "no_connection_to_daemon" in message:
|
||||||
|
return {"wallet_rpc": "ok", "daemon": "unreachable"}
|
||||||
|
return {"wallet_rpc": "ok", "daemon": "ok" if self._daemon_address else "unknown"}
|
||||||
|
|
||||||
|
def create_subaddress(self, user: User, label: str) -> SubaddressResult:
|
||||||
|
start_total = time.monotonic()
|
||||||
|
view_key = decrypt_secret(user.view_key_encrypted)
|
||||||
|
wallet_name = self._wallet_name(user, user.payment_address, view_key)
|
||||||
|
backend = self._backend_for_wallet_name(wallet_name)
|
||||||
|
start_wallet = time.monotonic()
|
||||||
|
self._ensure_wallet_open(
|
||||||
|
backend=backend,
|
||||||
|
wallet_name=wallet_name,
|
||||||
|
payment_address=user.payment_address,
|
||||||
|
view_key=view_key,
|
||||||
|
)
|
||||||
|
wallet_elapsed = time.monotonic() - start_wallet
|
||||||
|
start_daemon = time.monotonic()
|
||||||
|
self._ensure_daemon(backend)
|
||||||
|
daemon_elapsed = time.monotonic() - start_daemon
|
||||||
|
|
||||||
|
start_create = time.monotonic()
|
||||||
|
try:
|
||||||
|
response = backend.client.raw_request(
|
||||||
|
"create_address",
|
||||||
|
{"account_index": 0, "label": label},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
create_elapsed = time.monotonic() - start_create
|
||||||
|
address = response.get("address")
|
||||||
|
if not address:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Monero wallet RPC did not return a subaddress",
|
||||||
|
)
|
||||||
|
start_store = time.monotonic()
|
||||||
|
try:
|
||||||
|
backend.client.raw_request("store")
|
||||||
|
except RPCError:
|
||||||
|
# Storing can fail if the wallet RPC is in an odd state; the
|
||||||
|
# subaddress is still valid, but we prefer to surface issues later.
|
||||||
|
pass
|
||||||
|
store_elapsed = time.monotonic() - start_store
|
||||||
|
total_elapsed = time.monotonic() - start_total
|
||||||
|
logger.info(
|
||||||
|
"create_subaddress timing wallet=%.3fs daemon=%.3fs create=%.3fs store=%.3fs total=%.3fs",
|
||||||
|
wallet_elapsed,
|
||||||
|
daemon_elapsed,
|
||||||
|
create_elapsed,
|
||||||
|
store_elapsed,
|
||||||
|
total_elapsed,
|
||||||
|
)
|
||||||
|
return SubaddressResult(
|
||||||
|
address=address,
|
||||||
|
address_index=response.get("address_index"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_received_atomic(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
address: str,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
view_key = decrypt_secret(user.view_key_encrypted)
|
||||||
|
wallet_name = self._wallet_name(user, user.payment_address, view_key)
|
||||||
|
backend = self._backend_for_wallet_name(wallet_name)
|
||||||
|
self._ensure_wallet_open(
|
||||||
|
backend=backend,
|
||||||
|
wallet_name=wallet_name,
|
||||||
|
payment_address=user.payment_address,
|
||||||
|
view_key=view_key,
|
||||||
|
)
|
||||||
|
self._ensure_daemon(backend)
|
||||||
|
try:
|
||||||
|
index_response = backend.client.raw_request(
|
||||||
|
"get_address_index",
|
||||||
|
{"address": address},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
index = index_response.get("index") if isinstance(index_response, dict) else None
|
||||||
|
major = index.get("major") if isinstance(index, dict) else None
|
||||||
|
minor = index.get("minor") if isinstance(index, dict) else None
|
||||||
|
if major is None or minor is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Monero wallet RPC did not return an address index",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
transfers = backend.client.raw_request(
|
||||||
|
"get_transfers",
|
||||||
|
{
|
||||||
|
"in": True,
|
||||||
|
"pool": True,
|
||||||
|
"account_index": major,
|
||||||
|
"subaddr_indices": [minor],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
incoming = transfers.get("in", []) if isinstance(transfers, dict) else []
|
||||||
|
pool = transfers.get("pool", []) if isinstance(transfers, dict) else []
|
||||||
|
total_atomic = 0
|
||||||
|
max_confirmations = 0
|
||||||
|
seen_txids: set[str] = set()
|
||||||
|
for item in [*incoming, *pool]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
txid = item.get("txid")
|
||||||
|
if isinstance(txid, str):
|
||||||
|
if txid in seen_txids:
|
||||||
|
continue
|
||||||
|
seen_txids.add(txid)
|
||||||
|
total_atomic += int(item.get("amount", 0) or 0)
|
||||||
|
confirmations = int(item.get("confirmations", 0) or 0)
|
||||||
|
if confirmations > max_confirmations:
|
||||||
|
max_confirmations = confirmations
|
||||||
|
return total_atomic, max_confirmations
|
||||||
|
|
||||||
|
def get_transfers_for_address(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
address: str,
|
||||||
|
) -> list[TransferDetail]:
|
||||||
|
view_key = decrypt_secret(user.view_key_encrypted)
|
||||||
|
wallet_name = self._wallet_name(user, user.payment_address, view_key)
|
||||||
|
backend = self._backend_for_wallet_name(wallet_name)
|
||||||
|
self._ensure_wallet_open(
|
||||||
|
backend=backend,
|
||||||
|
wallet_name=wallet_name,
|
||||||
|
payment_address=user.payment_address,
|
||||||
|
view_key=view_key,
|
||||||
|
)
|
||||||
|
self._ensure_daemon(backend)
|
||||||
|
try:
|
||||||
|
index_response = backend.client.raw_request(
|
||||||
|
"get_address_index",
|
||||||
|
{"address": address},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
index = index_response.get("index") if isinstance(index_response, dict) else None
|
||||||
|
major = index.get("major") if isinstance(index, dict) else None
|
||||||
|
minor = index.get("minor") if isinstance(index, dict) else None
|
||||||
|
if major is None or minor is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Monero wallet RPC did not return an address index",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
transfers = backend.client.raw_request(
|
||||||
|
"get_transfers",
|
||||||
|
{
|
||||||
|
"in": True,
|
||||||
|
"pool": True,
|
||||||
|
"account_index": major,
|
||||||
|
"subaddr_indices": [minor],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
incoming = transfers.get("in", []) if isinstance(transfers, dict) else []
|
||||||
|
pool = transfers.get("pool", []) if isinstance(transfers, dict) else []
|
||||||
|
details: list[TransferDetail] = []
|
||||||
|
seen_txids: set[str] = set()
|
||||||
|
for item in [*incoming, *pool]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
txid = item.get("txid")
|
||||||
|
if not isinstance(txid, str) or not txid:
|
||||||
|
continue
|
||||||
|
if txid in seen_txids:
|
||||||
|
continue
|
||||||
|
seen_txids.add(txid)
|
||||||
|
amount_atomic = int(item.get("amount", 0) or 0)
|
||||||
|
confirmations = int(item.get("confirmations", 0) or 0)
|
||||||
|
timestamp = item.get("timestamp")
|
||||||
|
timestamp_value = int(timestamp) if isinstance(timestamp, (int, float)) else None
|
||||||
|
details.append(
|
||||||
|
TransferDetail(
|
||||||
|
txid=txid,
|
||||||
|
amount_atomic=amount_atomic,
|
||||||
|
confirmations=confirmations,
|
||||||
|
timestamp=timestamp_value,
|
||||||
|
address=item.get("address") if isinstance(item.get("address"), str) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return details
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rpc_error_message(exc: RPCError) -> str:
|
||||||
|
message = str(exc)
|
||||||
|
error = getattr(exc, "error", None)
|
||||||
|
if isinstance(error, dict):
|
||||||
|
detail = error.get("message")
|
||||||
|
if detail:
|
||||||
|
message = f"{message} {detail}"
|
||||||
|
return message
|
||||||
|
|
||||||
|
def _daemon_height(self) -> int | None:
|
||||||
|
if not self._daemon_url:
|
||||||
|
return None
|
||||||
|
url = self._daemon_url.rstrip("/")
|
||||||
|
payload = {"jsonrpc": "2.0", "id": "0", "method": "get_height"}
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{url}/json_rpc", json=payload, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
result = data.get("result") if isinstance(data, dict) else None
|
||||||
|
height = result.get("height") if isinstance(result, dict) else None
|
||||||
|
if isinstance(height, int):
|
||||||
|
return height
|
||||||
|
except (requests.RequestException, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{url}/get_height", timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except (requests.RequestException, ValueError):
|
||||||
|
return None
|
||||||
|
height = data.get("height") if isinstance(data, dict) else None
|
||||||
|
return height if isinstance(height, int) else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wallet_name(user: User, payment_address: str, view_key: str) -> str:
|
||||||
|
fingerprint = hashlib.sha256(
|
||||||
|
f"{payment_address}:{view_key}".encode("utf-8")
|
||||||
|
).hexdigest()[:12]
|
||||||
|
return f"user-{user.id}-{fingerprint}"
|
||||||
|
|
||||||
|
def _backend_for_wallet_name(self, wallet_name: str) -> WalletBackend:
|
||||||
|
digest = hashlib.sha256(wallet_name.encode("utf-8")).hexdigest()
|
||||||
|
index = int(digest, 16) % len(self._backends)
|
||||||
|
return self._backends[index]
|
||||||
|
|
||||||
|
def _ensure_wallet_open(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
backend: WalletBackend,
|
||||||
|
wallet_name: str,
|
||||||
|
payment_address: str,
|
||||||
|
view_key: str,
|
||||||
|
) -> None:
|
||||||
|
if backend.current_wallet == wallet_name:
|
||||||
|
return
|
||||||
|
close_start = time.monotonic()
|
||||||
|
try:
|
||||||
|
backend.client.raw_request("close_wallet")
|
||||||
|
except (RPCError, RequestException):
|
||||||
|
pass
|
||||||
|
close_elapsed = time.monotonic() - close_start
|
||||||
|
open_start = time.monotonic()
|
||||||
|
try:
|
||||||
|
backend.client.raw_request(
|
||||||
|
"open_wallet",
|
||||||
|
{"filename": wallet_name, "password": MONERO_WALLET_RPC_WALLET_PASSWORD},
|
||||||
|
)
|
||||||
|
backend.current_wallet = wallet_name
|
||||||
|
open_elapsed = time.monotonic() - open_start
|
||||||
|
logger.info(
|
||||||
|
"wallet open timing close=%.3fs open=%.3fs",
|
||||||
|
close_elapsed,
|
||||||
|
open_elapsed,
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException):
|
||||||
|
try:
|
||||||
|
restore_height = self._daemon_height() or 0
|
||||||
|
gen_start = time.monotonic()
|
||||||
|
# generate_from_keys opens the wallet when successful.
|
||||||
|
backend.client.raw_request(
|
||||||
|
"generate_from_keys",
|
||||||
|
{
|
||||||
|
"restore_height": restore_height,
|
||||||
|
"filename": wallet_name,
|
||||||
|
"address": payment_address,
|
||||||
|
"viewkey": view_key,
|
||||||
|
"password": MONERO_WALLET_RPC_WALLET_PASSWORD,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
backend.current_wallet = wallet_name
|
||||||
|
gen_elapsed = time.monotonic() - gen_start
|
||||||
|
open_elapsed = time.monotonic() - open_start
|
||||||
|
logger.info(
|
||||||
|
"wallet generate timing close=%.3fs open_fail=%.3fs generate=%.3fs",
|
||||||
|
close_elapsed,
|
||||||
|
open_elapsed,
|
||||||
|
gen_elapsed,
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as gen_exc:
|
||||||
|
if isinstance(gen_exc, RequestException):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Monero wallet RPC is unreachable",
|
||||||
|
) from gen_exc
|
||||||
|
gen_message = self._rpc_error_message(gen_exc).lower()
|
||||||
|
if "wallet_files_doesnt_correspond" in gen_message or "file_exists" in gen_message:
|
||||||
|
expected_path = (
|
||||||
|
f"{self._wallet_dir.rstrip('/')}/{wallet_name}"
|
||||||
|
if self._wallet_dir
|
||||||
|
else wallet_name
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"Wallet file exists but could not be opened. Verify the wallet "
|
||||||
|
"password, wallet directory, and that the cache file matches the "
|
||||||
|
".keys file. If needed, remove or rename the wallet cache file "
|
||||||
|
f"to rebuild it. Expected filename: {expected_path}"
|
||||||
|
),
|
||||||
|
) from gen_exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _ensure_daemon(self, backend: WalletBackend) -> None:
|
||||||
|
if not self._daemon_address:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
backend.client.raw_request(
|
||||||
|
"set_daemon",
|
||||||
|
{"address": self._daemon_address},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raise_wallet_rpc_error(exc: Exception) -> None:
|
||||||
|
if isinstance(exc, RequestException):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Monero wallet RPC is unreachable",
|
||||||
|
) from exc
|
||||||
|
message = str(exc)
|
||||||
|
if "no connection to daemon" in message or "no_connection_to_daemon" in message:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Wallet RPC cannot reach the Monero daemon",
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
import qrcode
|
||||||
|
from qrcode.constants import ERROR_CORRECT_H
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .formatting import format_xmr_amount
|
||||||
|
from .models import Invoice
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QrSettings:
|
||||||
|
logo: str # "monero" | "none" | "custom"
|
||||||
|
logo_data_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def invoice_qr_url(invoice_id: str) -> str:
|
||||||
|
return f"/qr/{invoice_id}.png"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_invoice_qr_png(
|
||||||
|
*,
|
||||||
|
invoice: Invoice,
|
||||||
|
storage_dir: str,
|
||||||
|
settings: QrSettings,
|
||||||
|
) -> str:
|
||||||
|
if not storage_dir:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="QR storage is not configured",
|
||||||
|
)
|
||||||
|
os.makedirs(storage_dir, exist_ok=True)
|
||||||
|
filename = f"{invoice.id}.png"
|
||||||
|
path = os.path.join(storage_dir, filename)
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings)
|
||||||
|
_atomic_write(path, png_bytes)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def build_invoice_qr_png_bytes(*, invoice: Invoice, settings: QrSettings) -> bytes:
|
||||||
|
uri = build_monero_uri(invoice)
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
error_correction=ERROR_CORRECT_H,
|
||||||
|
border=2,
|
||||||
|
box_size=10,
|
||||||
|
)
|
||||||
|
qr.add_data(uri)
|
||||||
|
qr.make(fit=True)
|
||||||
|
image = qr.make_image(fill_color="black", back_color="white").convert("RGBA")
|
||||||
|
|
||||||
|
if settings.logo == "custom" and settings.logo_data_url:
|
||||||
|
logo = _load_logo_from_data_url(settings.logo_data_url)
|
||||||
|
image = _overlay_logo(image, logo)
|
||||||
|
|
||||||
|
out = tempfile.SpooledTemporaryFile(max_size=1024 * 1024)
|
||||||
|
image.save(out, format="PNG", optimize=True)
|
||||||
|
out.seek(0)
|
||||||
|
return out.read()
|
||||||
|
|
||||||
|
|
||||||
|
def build_monero_uri(invoice: Invoice) -> str:
|
||||||
|
amount = format_xmr_amount(invoice.amount_xmr)
|
||||||
|
params: dict[str, str] = {"tx_amount": amount}
|
||||||
|
metadata = invoice.metadata_json or {}
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
recipient_name = metadata.get("recipient_name")
|
||||||
|
description = metadata.get("description")
|
||||||
|
if isinstance(recipient_name, str) and recipient_name.strip():
|
||||||
|
params["recipient_name"] = recipient_name.strip()
|
||||||
|
if isinstance(description, str) and description.strip():
|
||||||
|
params["tx_description"] = description.strip()
|
||||||
|
query = "&".join(f"{_url_escape(k)}={_url_escape(v)}" for k, v in params.items())
|
||||||
|
return f"monero:{invoice.address}?{query}"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_qr_settings(invoice: Invoice) -> QrSettings:
|
||||||
|
metadata: Any = invoice.metadata_json or {}
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return QrSettings(logo="monero", logo_data_url=None)
|
||||||
|
qr_meta = metadata.get("qr")
|
||||||
|
if not isinstance(qr_meta, dict):
|
||||||
|
return QrSettings(logo="monero", logo_data_url=None)
|
||||||
|
logo = qr_meta.get("logo")
|
||||||
|
logo_data_url = qr_meta.get("logo_data_url")
|
||||||
|
if not isinstance(logo, str) or logo not in ("monero", "none", "custom"):
|
||||||
|
return QrSettings(logo="monero", logo_data_url=None)
|
||||||
|
if logo == "custom" and isinstance(logo_data_url, str) and logo_data_url.strip():
|
||||||
|
return QrSettings(logo="custom", logo_data_url=logo_data_url.strip())
|
||||||
|
return QrSettings(logo=logo, logo_data_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(path: str, data: bytes) -> None:
|
||||||
|
directory = os.path.dirname(path) or "."
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as tmp_file:
|
||||||
|
tmp_file.write(data)
|
||||||
|
tmp_file.flush()
|
||||||
|
os.fsync(tmp_file.fileno())
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _load_logo_from_data_url(data_url: str) -> Image.Image:
|
||||||
|
# Expected: data:image/png;base64,<...>
|
||||||
|
try:
|
||||||
|
header, encoded = data_url.split(",", 1)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="QR logo must be a data URL",
|
||||||
|
) from exc
|
||||||
|
if ";base64" not in header:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="QR logo must be base64 encoded",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(encoded, validate=True)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="QR logo could not be decoded",
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
logo = Image.open(BytesIO(raw)).convert("RGBA")
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="QR logo image format is not supported",
|
||||||
|
) from exc
|
||||||
|
return logo
|
||||||
|
|
||||||
|
|
||||||
|
def _overlay_logo(qr_image: Image.Image, logo: Image.Image) -> Image.Image:
|
||||||
|
# Center a small logo with a white backing.
|
||||||
|
size = min(qr_image.size)
|
||||||
|
target_logo_size = int(size * 0.22)
|
||||||
|
if target_logo_size < 24:
|
||||||
|
return qr_image
|
||||||
|
logo = logo.copy()
|
||||||
|
logo.thumbnail((target_logo_size, target_logo_size), Image.LANCZOS)
|
||||||
|
|
||||||
|
overlay = Image.new("RGBA", qr_image.size, (255, 255, 255, 0))
|
||||||
|
box_size = int(size * 0.28)
|
||||||
|
box_offset = int((size - box_size) / 2)
|
||||||
|
box = Image.new("RGBA", (box_size, box_size), (255, 255, 255, 255))
|
||||||
|
overlay.paste(box, (box_offset, box_offset))
|
||||||
|
|
||||||
|
logo_offset = (int((size - logo.size[0]) / 2), int((size - logo.size[1]) / 2))
|
||||||
|
overlay.paste(logo, logo_offset, mask=logo)
|
||||||
|
|
||||||
|
return Image.alpha_composite(qr_image, overlay)
|
||||||
|
|
||||||
|
|
||||||
|
def _url_escape(value: str) -> str:
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
return quote(value, safe="")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .config import COINGECKO_API_KEY
|
||||||
|
|
||||||
|
COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price"
|
||||||
|
COINGECKO_SOURCE = "coingecko"
|
||||||
|
RATE_TTL_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QuoteResult:
|
||||||
|
rate: Decimal
|
||||||
|
currency: str
|
||||||
|
source: str
|
||||||
|
quoted_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_cached_quote: QuoteResult | None = None
|
||||||
|
_cached_at: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_xmr_rate(currency: str) -> QuoteResult:
|
||||||
|
global _cached_quote, _cached_at
|
||||||
|
normalized_currency = currency.strip().lower()
|
||||||
|
if not COINGECKO_API_KEY:
|
||||||
|
raise RuntimeError("CoinGecko API key is not configured")
|
||||||
|
|
||||||
|
with _cache_lock:
|
||||||
|
if _cached_quote and _cached_at:
|
||||||
|
if time.monotonic() - _cached_at < RATE_TTL_SECONDS:
|
||||||
|
return _cached_quote
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"vs_currencies": normalized_currency,
|
||||||
|
"ids": "monero",
|
||||||
|
"x_cg_demo_api_key": COINGECKO_API_KEY,
|
||||||
|
}
|
||||||
|
response = requests.get(COINGECKO_URL, params=params, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
data: dict[str, Any] = response.json()
|
||||||
|
rate_value = (
|
||||||
|
data.get("monero", {}).get(normalized_currency)
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if rate_value is None:
|
||||||
|
raise ValueError("Unsupported fiat currency at this time")
|
||||||
|
rate = Decimal(str(rate_value))
|
||||||
|
quote = QuoteResult(
|
||||||
|
rate=rate,
|
||||||
|
currency=normalized_currency.upper(),
|
||||||
|
source=COINGECKO_SOURCE,
|
||||||
|
quoted_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
with _cache_lock:
|
||||||
|
_cached_quote = quote
|
||||||
|
_cached_at = time.monotonic()
|
||||||
|
return quote
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from decimal import Decimal, ROUND_DOWN
|
||||||
|
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .btcpay_webhooks import dispatch_btcpay_webhooks
|
||||||
|
from .config import INVOICE_RECONCILE_INTERVAL_SECONDS, LATE_PAYMENT_LOOKBACK_HOURS
|
||||||
|
from .db import SessionLocal
|
||||||
|
from .models import Invoice, InvoiceTransfer, User
|
||||||
|
from .monero_service import MoneroWalletService, TransferDetail
|
||||||
|
from .webhooks import dispatch_webhooks
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
level_name = "INFO"
|
||||||
|
try:
|
||||||
|
level_name = __import__("os").getenv("LOG_LEVEL", "INFO")
|
||||||
|
except Exception:
|
||||||
|
level_name = "INFO"
|
||||||
|
level = getattr(logging, level_name.upper(), logging.INFO)
|
||||||
|
logging.basicConfig(level=level)
|
||||||
|
service = MoneroWalletService()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
_reconcile_invoices(service)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Invoice reconcile failed: %s", exc)
|
||||||
|
time.sleep(INVOICE_RECONCILE_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_invoices(service: MoneroWalletService) -> None:
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
late_cutoff = now - timedelta(hours=max(0, LATE_PAYMENT_LOOKBACK_HOURS))
|
||||||
|
invoices = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
Invoice.status.in_(["pending", "payment_detected"]),
|
||||||
|
and_(
|
||||||
|
Invoice.status == "expired",
|
||||||
|
Invoice.expires_at.is_not(None),
|
||||||
|
Invoice.expires_at >= late_cutoff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Invoice.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
user_groups: dict[object, list[Invoice]] = {}
|
||||||
|
for invoice in invoices:
|
||||||
|
if invoice.user_id is None:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping invoice without user",
|
||||||
|
extra={"invoice_id": str(invoice.id)},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
user_groups.setdefault(invoice.user_id, []).append(invoice)
|
||||||
|
logger.debug(
|
||||||
|
"Reconciling %d invoices across %d users",
|
||||||
|
len(invoices),
|
||||||
|
len(user_groups),
|
||||||
|
)
|
||||||
|
for user_id, user_invoices in user_groups.items():
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if user is None:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping invoices with missing user",
|
||||||
|
extra={"user_id": str(user_id)},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not user.payment_address or not user.view_key_encrypted:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping invoices without payment address",
|
||||||
|
extra={"user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for invoice in user_invoices:
|
||||||
|
try:
|
||||||
|
transfers = service.get_transfers_for_address(
|
||||||
|
user=user,
|
||||||
|
address=invoice.address,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping invoice reconcile due to wallet RPC error",
|
||||||
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
logger.debug("Wallet RPC error: %s", exc)
|
||||||
|
continue
|
||||||
|
total_atomic = 0
|
||||||
|
max_confirmations = 0
|
||||||
|
for transfer in transfers:
|
||||||
|
if transfer.amount_atomic <= 0:
|
||||||
|
continue
|
||||||
|
total_atomic += transfer.amount_atomic
|
||||||
|
if transfer.confirmations > max_confirmations:
|
||||||
|
max_confirmations = transfer.confirmations
|
||||||
|
logger.debug(
|
||||||
|
"Invoice totals",
|
||||||
|
extra={
|
||||||
|
"invoice_id": str(invoice.id),
|
||||||
|
"received_atomic": total_atomic,
|
||||||
|
"confirmations": max_confirmations,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
previous_confirmations = invoice.confirmations or 0
|
||||||
|
total_changed = invoice.total_paid_atomic != total_atomic
|
||||||
|
confirmations_changed = previous_confirmations != max_confirmations
|
||||||
|
transfers_changed = _sync_invoice_transfers(
|
||||||
|
db,
|
||||||
|
invoice=invoice,
|
||||||
|
transfers=transfers,
|
||||||
|
)
|
||||||
|
if total_changed or confirmations_changed or transfers_changed:
|
||||||
|
if confirmations_changed:
|
||||||
|
invoice.confirmations = max_confirmations
|
||||||
|
if total_changed:
|
||||||
|
invoice.total_paid_atomic = total_atomic
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
required_atomic = _xmr_to_atomic(invoice.amount_xmr)
|
||||||
|
is_paid = total_atomic >= required_atomic
|
||||||
|
|
||||||
|
expires_at = invoice.expires_at
|
||||||
|
if expires_at is not None and expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
is_after_expiry = bool(expires_at and now >= expires_at)
|
||||||
|
|
||||||
|
if total_atomic < required_atomic:
|
||||||
|
logger.debug(
|
||||||
|
"Payment not yet detected",
|
||||||
|
extra={
|
||||||
|
"invoice_id": str(invoice.id),
|
||||||
|
"required_atomic": required_atomic,
|
||||||
|
"received_atomic": total_atomic,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if invoice.status == "pending" and is_after_expiry:
|
||||||
|
logger.info(
|
||||||
|
"Invoice expired",
|
||||||
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
invoice.status = "expired"
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
dispatch_webhooks(db, str(user.id), "invoice.expired", invoice)
|
||||||
|
dispatch_btcpay_webhooks(
|
||||||
|
db, str(user.id), "InvoiceExpired", invoice
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_paid and invoice.status in ("pending", "expired"):
|
||||||
|
logger.info(
|
||||||
|
"Invoice marked payment detected",
|
||||||
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
previous_status = invoice.status
|
||||||
|
invoice.status = "payment_detected"
|
||||||
|
if invoice.detected_at is None:
|
||||||
|
invoice.detected_at = now
|
||||||
|
if previous_status == "expired" or (previous_status == "pending" and is_after_expiry):
|
||||||
|
invoice.paid_after_expiry = True
|
||||||
|
if invoice.paid_after_expiry_at is None:
|
||||||
|
invoice.paid_after_expiry_at = now
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
dispatch_webhooks(db, str(user.id), "invoice.payment_detected", invoice)
|
||||||
|
dispatch_btcpay_webhooks(
|
||||||
|
db, str(user.id), "InvoiceReceivedPayment", invoice
|
||||||
|
)
|
||||||
|
dispatch_btcpay_webhooks(
|
||||||
|
db, str(user.id), "InvoiceProcessing", invoice
|
||||||
|
)
|
||||||
|
if max_confirmations >= invoice.confirmation_target and invoice.status != "confirmed":
|
||||||
|
logger.info(
|
||||||
|
"Invoice confirmed",
|
||||||
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
invoice.status = "confirmed"
|
||||||
|
if invoice.confirmed_at is None:
|
||||||
|
invoice.confirmed_at = now
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
dispatch_webhooks(db, str(user.id), "invoice.confirmed", invoice)
|
||||||
|
dispatch_btcpay_webhooks(db, str(user.id), "InvoiceSettled", invoice)
|
||||||
|
dispatch_btcpay_webhooks(
|
||||||
|
db, str(user.id), "InvoicePaymentSettled", invoice
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_invoice_transfers(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
invoice: Invoice,
|
||||||
|
transfers: list[TransferDetail],
|
||||||
|
) -> bool:
|
||||||
|
existing = (
|
||||||
|
db.query(InvoiceTransfer)
|
||||||
|
.filter(InvoiceTransfer.invoice_id == invoice.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
existing_by_txid = {transfer.txid: transfer for transfer in existing if transfer.txid}
|
||||||
|
seen_txids: set[str] = set()
|
||||||
|
changed = False
|
||||||
|
for transfer in transfers:
|
||||||
|
txid = transfer.txid
|
||||||
|
if not txid:
|
||||||
|
continue
|
||||||
|
seen_txids.add(txid)
|
||||||
|
stored = existing_by_txid.get(txid)
|
||||||
|
if stored is None:
|
||||||
|
db.add(
|
||||||
|
InvoiceTransfer(
|
||||||
|
invoice_id=invoice.id,
|
||||||
|
txid=txid,
|
||||||
|
amount_atomic=transfer.amount_atomic,
|
||||||
|
confirmations=transfer.confirmations,
|
||||||
|
timestamp=transfer.timestamp,
|
||||||
|
address=transfer.address,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
stored.amount_atomic != transfer.amount_atomic
|
||||||
|
or stored.confirmations != transfer.confirmations
|
||||||
|
or stored.timestamp != transfer.timestamp
|
||||||
|
or stored.address != transfer.address
|
||||||
|
):
|
||||||
|
stored.amount_atomic = transfer.amount_atomic
|
||||||
|
stored.confirmations = transfer.confirmations
|
||||||
|
stored.timestamp = transfer.timestamp
|
||||||
|
stored.address = transfer.address
|
||||||
|
changed = True
|
||||||
|
for stored in existing:
|
||||||
|
if stored.txid not in seen_txids:
|
||||||
|
db.delete(stored)
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _xmr_to_atomic(amount: Decimal) -> int:
|
||||||
|
quantized = (Decimal(amount) * Decimal("1000000000000")).to_integral_value(
|
||||||
|
rounding=ROUND_DOWN
|
||||||
|
)
|
||||||
|
return int(quantized)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+1214
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
AnyUrl,
|
||||||
|
BaseModel,
|
||||||
|
ConfigDict,
|
||||||
|
Field,
|
||||||
|
conint,
|
||||||
|
field_serializer,
|
||||||
|
field_validator,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .formatting import format_xmr_amount
|
||||||
|
|
||||||
|
InvoiceStatus = Literal["pending", "payment_detected", "confirmed", "expired", "invalid"]
|
||||||
|
BtcpayCheckoutStyle = Literal["standard", "btcpay_classic"]
|
||||||
|
QrLogoMode = Literal["monero", "none", "custom"]
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceCreate(BaseModel):
|
||||||
|
amount_xmr: Decimal | None = Field(default=None, gt=0)
|
||||||
|
amount_fiat: Decimal | None = Field(default=None, gt=0)
|
||||||
|
currency: str | None = None
|
||||||
|
confirmation_target: conint(ge=0, le=10) = 10
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
expires_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Defaults to 24 hours from creation if omitted.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_currency(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip().upper()
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_amounts(self) -> "InvoiceCreate":
|
||||||
|
if self.amount_xmr is None and self.amount_fiat is None:
|
||||||
|
raise ValueError("amount_xmr or amount_fiat is required")
|
||||||
|
if self.amount_xmr is not None and self.amount_fiat is not None:
|
||||||
|
raise ValueError("Provide either amount_xmr or amount_fiat, not both")
|
||||||
|
if self.amount_fiat is not None and not self.currency:
|
||||||
|
raise ValueError("currency is required when amount_fiat is provided")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class DonationCreate(BaseModel):
|
||||||
|
amount_xmr: Decimal = Field(gt=0)
|
||||||
|
confirmation_target: conint(ge=0, le=10) = 10
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
address: str
|
||||||
|
subaddress_index: int | None = None
|
||||||
|
amount_xmr: Decimal
|
||||||
|
status: InvoiceStatus
|
||||||
|
confirmation_target: int
|
||||||
|
confirmations: int = 0
|
||||||
|
paid_after_expiry: bool = False
|
||||||
|
paid_after_expiry_at: datetime | None = None
|
||||||
|
qr_logo: QrLogoMode | None = None
|
||||||
|
qr_logo_data_url: str | None = None
|
||||||
|
metadata: dict[str, Any] | None = Field(default=None, validation_alias="metadata_json")
|
||||||
|
warnings: list[str] | None = None
|
||||||
|
quote: dict[str, Any] | None = None
|
||||||
|
invoice_url: str | None = None
|
||||||
|
qr_url: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
archived_at: datetime | None = None
|
||||||
|
expires_at: datetime | None
|
||||||
|
detected_at: datetime | None
|
||||||
|
confirmed_at: datetime | None
|
||||||
|
|
||||||
|
@field_serializer("amount_xmr")
|
||||||
|
def _serialize_amount_xmr(self, value: Decimal) -> str:
|
||||||
|
return format_xmr_amount(value)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceCreateUser(BaseModel):
|
||||||
|
amount_xmr: Decimal | None = Field(default=None, gt=0)
|
||||||
|
amount_fiat: Decimal | None = Field(default=None, gt=0)
|
||||||
|
currency: str | None = None
|
||||||
|
confirmation_target: conint(ge=0, le=10) = 10
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
expires_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Defaults to 24 hours from creation if omitted.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_currency(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip().upper()
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_amounts(self) -> "InvoiceCreateUser":
|
||||||
|
if self.amount_xmr is None and self.amount_fiat is None:
|
||||||
|
raise ValueError("amount_xmr or amount_fiat is required")
|
||||||
|
if self.amount_xmr is not None and self.amount_fiat is not None:
|
||||||
|
raise ValueError("Provide either amount_xmr or amount_fiat, not both")
|
||||||
|
if self.amount_fiat is not None and not self.currency:
|
||||||
|
raise ValueError("currency is required when amount_fiat is provided")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceListResponse(BaseModel):
|
||||||
|
items: list[InvoiceResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceStatusResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
address: str
|
||||||
|
subaddress_index: int | None = None
|
||||||
|
amount_xmr: Decimal
|
||||||
|
status: InvoiceStatus
|
||||||
|
confirmation_target: int
|
||||||
|
confirmations: int = 0
|
||||||
|
paid_after_expiry: bool = False
|
||||||
|
paid_after_expiry_at: datetime | None = None
|
||||||
|
qr_logo: QrLogoMode | None = None
|
||||||
|
qr_logo_data_url: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime | None
|
||||||
|
detected_at: datetime | None
|
||||||
|
confirmed_at: datetime | None
|
||||||
|
btcpay_amount: str | None = None
|
||||||
|
btcpay_currency: str | None = None
|
||||||
|
btcpay_checkout_style: BtcpayCheckoutStyle | None = None
|
||||||
|
quote: dict[str, Any] | None = None
|
||||||
|
qr_url: str | None = None
|
||||||
|
|
||||||
|
@field_serializer("amount_xmr")
|
||||||
|
def _serialize_amount_xmr(self, value: Decimal) -> str:
|
||||||
|
return format_xmr_amount(value)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
payment_address: str
|
||||||
|
default_confirmation_target: conint(ge=0, le=10) = 10
|
||||||
|
default_qr_logo: QrLogoMode = "monero"
|
||||||
|
default_qr_logo_data_url: str | None = None
|
||||||
|
btcpay_checkout_style: BtcpayCheckoutStyle = "standard"
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
btcpay_checkout_style: BtcpayCheckoutStyle | None = None
|
||||||
|
default_confirmation_target: conint(ge=0, le=10) | None = None
|
||||||
|
default_qr_logo: QrLogoMode | None = None
|
||||||
|
default_qr_logo_data_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookCreate(BaseModel):
|
||||||
|
url: AnyUrl | None = None
|
||||||
|
events: list[
|
||||||
|
Literal[
|
||||||
|
"invoice.created",
|
||||||
|
"invoice.payment_detected",
|
||||||
|
"invoice.confirmed",
|
||||||
|
"invoice.expired",
|
||||||
|
]
|
||||||
|
] | None = None
|
||||||
|
event_urls: dict[
|
||||||
|
Literal[
|
||||||
|
"invoice.created",
|
||||||
|
"invoice.payment_detected",
|
||||||
|
"invoice.confirmed",
|
||||||
|
"invoice.expired",
|
||||||
|
],
|
||||||
|
AnyUrl,
|
||||||
|
] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
url: AnyUrl | None = None
|
||||||
|
events: list[str]
|
||||||
|
event_urls: dict[str, AnyUrl] | None = None
|
||||||
|
active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookDeliveryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
webhook_id: UUID | None = None
|
||||||
|
event: str
|
||||||
|
url: str
|
||||||
|
invoice_id: UUID | None = None
|
||||||
|
invoice_address: str | None = None
|
||||||
|
invoice_subaddress_index: int | None = None
|
||||||
|
invoice_amount_xmr: Decimal | None = None
|
||||||
|
invoice_status: InvoiceStatus | None = None
|
||||||
|
http_status: int | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
@field_serializer("invoice_amount_xmr")
|
||||||
|
def _serialize_invoice_amount_xmr(self, value: Decimal | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return format_xmr_amount(value)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
payment_address: str = Field(min_length=1)
|
||||||
|
view_key: str = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
api_key: str
|
||||||
|
webhook_secret: str
|
||||||
|
store_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class ApiCredentialsResetRequest(BaseModel):
|
||||||
|
reset_api_key: bool = False
|
||||||
|
reset_webhook_secret: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ApiCredentialsResetResponse(BaseModel):
|
||||||
|
api_key: str | None = None
|
||||||
|
webhook_secret: str | None = None
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
API_KEYS,
|
||||||
|
API_KEY_ENCRYPTION_KEY,
|
||||||
|
)
|
||||||
|
from .db import get_db
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_authorization_api_key(authorization: str | None) -> str | None:
|
||||||
|
if not authorization:
|
||||||
|
return None
|
||||||
|
scheme, _, token = authorization.partition(" ")
|
||||||
|
if scheme.lower() not in ("apikey", "token"):
|
||||||
|
return None
|
||||||
|
token = token.strip()
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
x_api_key: str | None = Header(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> str:
|
||||||
|
api_key = _parse_authorization_api_key(authorization) or x_api_key
|
||||||
|
if api_key is None or api_key not in API_KEYS:
|
||||||
|
if api_key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid API key",
|
||||||
|
)
|
||||||
|
api_key_hash = hash_api_key(api_key)
|
||||||
|
user = db.query(User).filter(User.api_key_hash == api_key_hash).first()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid API key",
|
||||||
|
)
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
return _pwd_context.verify(password, password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key() -> str:
|
||||||
|
return f"xmrcheckout_{secrets.token_urlsafe(32)}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_webhook_secret() -> str:
|
||||||
|
return f"whsec_{secrets.token_urlsafe(32)}"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_api_key(api_key: str) -> str:
|
||||||
|
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_api_key(api_key: str) -> str:
|
||||||
|
return encrypt_secret(api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_api_key(api_key_encrypted: str) -> str:
|
||||||
|
return decrypt_secret(api_key_encrypted)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(value: str) -> str:
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
return Fernet(API_KEY_ENCRYPTION_KEY).encrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(value_encrypted: str) -> str:
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
return Fernet(API_KEY_ENCRYPTION_KEY).decrypt(
|
||||||
|
value_encrypted.encode("utf-8")
|
||||||
|
).decode("utf-8")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
from .security import decrypt_secret
|
||||||
|
from .subaddress_derivation import derive_subaddress
|
||||||
|
|
||||||
|
|
||||||
|
MAX_SUBADDRESS_INDEX = 100
|
||||||
|
|
||||||
|
|
||||||
|
def create_subaddress_for_user(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
) -> tuple[str, int | None]:
|
||||||
|
locked = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(
|
||||||
|
User.id == user.id,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if locked is None:
|
||||||
|
raise ValueError("User not found")
|
||||||
|
start_index = int(locked.subaddress_start_index or 0)
|
||||||
|
if start_index < 0 or start_index >= MAX_SUBADDRESS_INDEX:
|
||||||
|
start_index = 0
|
||||||
|
range_start = start_index + 1
|
||||||
|
range_end = MAX_SUBADDRESS_INDEX
|
||||||
|
next_index = int(locked.next_subaddress_index or range_start)
|
||||||
|
if next_index < range_start or next_index > range_end:
|
||||||
|
next_index = range_start
|
||||||
|
next_next_index = next_index + 1
|
||||||
|
if next_next_index > range_end:
|
||||||
|
next_next_index = range_start
|
||||||
|
locked.next_subaddress_index = next_next_index
|
||||||
|
db.flush()
|
||||||
|
view_key = decrypt_secret(locked.view_key_encrypted)
|
||||||
|
address = derive_subaddress(
|
||||||
|
payment_address=locked.payment_address,
|
||||||
|
view_key=view_key,
|
||||||
|
account_index=0,
|
||||||
|
address_index=next_index,
|
||||||
|
)
|
||||||
|
return address, int(next_index)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from binascii import hexlify, unhexlify
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from monero import base58, const, ed25519
|
||||||
|
from monero.address import Address
|
||||||
|
from monero.keccak import keccak_256
|
||||||
|
|
||||||
|
|
||||||
|
def derive_subaddress(
|
||||||
|
*,
|
||||||
|
payment_address: str,
|
||||||
|
view_key: str,
|
||||||
|
account_index: int,
|
||||||
|
address_index: int,
|
||||||
|
) -> str:
|
||||||
|
address = Address(payment_address)
|
||||||
|
view_key_bytes = unhexlify(view_key)
|
||||||
|
spend_pub_bytes = unhexlify(address.spend_key())
|
||||||
|
|
||||||
|
data = (
|
||||||
|
b"SubAddr\0"
|
||||||
|
+ view_key_bytes
|
||||||
|
+ struct.pack("<II", account_index, address_index)
|
||||||
|
)
|
||||||
|
m = ed25519.scalar_reduce(keccak_256(data).digest())
|
||||||
|
mG = ed25519.scalarmult_B(m)
|
||||||
|
D = ed25519.edwards_add(spend_pub_bytes, mG)
|
||||||
|
C = ed25519.scalarmult(view_key_bytes, D)
|
||||||
|
prefix = const.SUBADDR_NETBYTES[const.NETS.index(address.net)]
|
||||||
|
payload = bytearray([prefix]) + D + C
|
||||||
|
checksum = keccak_256(payload).digest()[:4]
|
||||||
|
return base58.encode(hexlify(payload + checksum))
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests import RequestException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .formatting import format_xmr_amount
|
||||||
|
from .models import Invoice, User, Webhook, WebhookDelivery
|
||||||
|
from .security import decrypt_secret, encrypt_secret, generate_webhook_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_webhook_payload(event: str, invoice: Invoice) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"event": event,
|
||||||
|
"invoice": {
|
||||||
|
"id": str(invoice.id),
|
||||||
|
"address": invoice.address,
|
||||||
|
"subaddress_index": invoice.subaddress_index,
|
||||||
|
"amount_xmr": format_xmr_amount(invoice.amount_xmr),
|
||||||
|
"status": invoice.status,
|
||||||
|
"confirmation_target": invoice.confirmation_target,
|
||||||
|
"confirmations": invoice.confirmations or 0,
|
||||||
|
"paid_after_expiry": bool(invoice.paid_after_expiry),
|
||||||
|
"paid_after_expiry_at": _isoformat(invoice.paid_after_expiry_at),
|
||||||
|
"metadata": invoice.metadata_json,
|
||||||
|
"created_at": _isoformat(invoice.created_at),
|
||||||
|
"expires_at": _isoformat(invoice.expires_at),
|
||||||
|
"detected_at": _isoformat(invoice.detected_at),
|
||||||
|
"confirmed_at": _isoformat(invoice.confirmed_at),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_webhooks(
|
||||||
|
db: Session,
|
||||||
|
user_id: str,
|
||||||
|
event: str,
|
||||||
|
invoice: Invoice,
|
||||||
|
) -> None:
|
||||||
|
hooks = (
|
||||||
|
db.query(Webhook)
|
||||||
|
.filter(
|
||||||
|
Webhook.user_id == user_id,
|
||||||
|
Webhook.active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
webhook_secret = None
|
||||||
|
if user is not None:
|
||||||
|
webhook_secret = _ensure_webhook_secret(db, user)
|
||||||
|
payload = build_webhook_payload(event, invoice)
|
||||||
|
deliveries: list[WebhookDelivery] = []
|
||||||
|
user_uuid = uuid.UUID(user_id)
|
||||||
|
for hook in hooks:
|
||||||
|
if event not in hook.events:
|
||||||
|
continue
|
||||||
|
hook_urls = hook.event_urls or {}
|
||||||
|
target_url = hook_urls.get(event, hook.url)
|
||||||
|
if not target_url:
|
||||||
|
logger.warning(
|
||||||
|
"Webhook URL missing for event",
|
||||||
|
extra={"webhook_id": str(hook.id), "event": event},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
status_code = None
|
||||||
|
error_message = None
|
||||||
|
try:
|
||||||
|
headers = {"X-Webhook-Secret": webhook_secret} if webhook_secret else None
|
||||||
|
response = requests.post(target_url, json=payload, headers=headers, timeout=5)
|
||||||
|
status_code = response.status_code
|
||||||
|
except RequestException as exc:
|
||||||
|
error_message = str(exc)
|
||||||
|
logger.warning(
|
||||||
|
"Webhook delivery failed",
|
||||||
|
extra={"webhook_id": str(hook.id), "event": event},
|
||||||
|
)
|
||||||
|
logger.debug("Webhook delivery error: %s", exc)
|
||||||
|
deliveries.append(
|
||||||
|
WebhookDelivery(
|
||||||
|
user_id=user_uuid,
|
||||||
|
webhook_id=hook.id,
|
||||||
|
event=event,
|
||||||
|
url=target_url,
|
||||||
|
invoice_id=invoice.id,
|
||||||
|
invoice_address=invoice.address,
|
||||||
|
invoice_subaddress_index=invoice.subaddress_index,
|
||||||
|
invoice_amount_xmr=invoice.amount_xmr,
|
||||||
|
invoice_status=invoice.status,
|
||||||
|
payload_json=payload,
|
||||||
|
http_status=status_code,
|
||||||
|
error_message=error_message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if deliveries:
|
||||||
|
try:
|
||||||
|
db.add_all(deliveries)
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
logger.warning("Failed to save webhook delivery history", extra={"error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
def _isoformat(value: datetime | None) -> str | None:
|
||||||
|
return value.isoformat() if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_webhook_secret(db: Session, user: User) -> str:
|
||||||
|
if user.webhook_secret_encrypted:
|
||||||
|
return decrypt_secret(user.webhook_secret_encrypted)
|
||||||
|
secret = generate_webhook_secret()
|
||||||
|
user.webhook_secret_encrypted = encrypt_secret(secret)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return secret
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
gunicorn==23.0.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
SQLAlchemy==2.0.36
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pydantic==2.9.2
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
cryptography==42.0.8
|
||||||
|
email-validator==2.2.0
|
||||||
|
monero==1.1.1
|
||||||
|
qrcode==7.4.2
|
||||||
|
Pillow==11.1.0
|
||||||
Reference in New Issue
Block a user