Fork xmrcheckout for Wownero (WOW)

Minimal changes from upstream xmrcheckout to support Wownero:
- Rate provider: Nonlogs WOW-BTC via Kraken BTC/USD
- Subaddress derivation: Wownero network byte overrides (53/63/54)
- Payment URI scheme: wownero:
- Dockerfile: Wownero binaries from Codeberg
- Display text: Monero→Wownero, XMR→WOW throughout UI
- Default daemon: 192.168.88.235:34568
- Added wownero-logo.svg

File and folder structure kept identical to upstream for easy sync.
This commit is contained in:
jwinterm
2026-03-09 11:08:55 -04:00
commit f8f9b4424c
111 changed files with 23058 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
DATABASE_URL=postgresql://wowcheckout:wowcheckout@localhost:5432/wowcheckout
API_KEYS=change-me-1,change-me-2
RATE_PROVIDER=nonlogs
INVOICE_RECONCILE_INTERVAL_SECONDS=30
FOUNDER_PAYMENT_ADDRESS=
FOUNDER_VIEW_KEY=
DONATIONS_ENABLED=false
+12
View File
@@ -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}"]
+1
View File
@@ -0,0 +1 @@
"""API package."""
+704
View File
@@ -0,0 +1,704 @@
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_wow_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 = "WOW-CHAIN"
BTCPAY_PAYMENT_METHOD_ALIASES = {BTCPAY_PAYMENT_METHOD, "WOW", "WOW_CHAIN"}
BTCPAY_WEBHOOK_EVENTS = {
"InvoiceCreated",
"InvoiceReceivedPayment",
"InvoicePaidInFull",
"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": "WOW Checkout",
"website": "",
"defaultCurrency": "WOW",
"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": "WOW 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() == "WOW":
return amount, None
try:
quote = get_wow_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), "WOW", {}
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": "WOW",
}
]
@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)
dispatch_btcpay_webhooks(db, str(user.id), "InvoiceCreated", 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": "WOW",
"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": "WOW 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
+65
View File
@@ -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)
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import time
from datetime import datetime, timezone
from urllib.parse import urljoin
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__)
_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
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",
"User-Agent": "wowcheckout-btcpay-webhook/1.0",
}
response = _post_with_redirects(
hook.url,
data=body,
headers=headers,
timeout=5,
)
if response is None:
continue
if response.status_code >= 400:
logger.warning(
"BTCPay webhook delivered non-success status",
extra={
"webhook_id": str(hook.id),
"event": event_type,
"http_status": response.status_code,
},
)
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()
def _post_with_redirects(
url: str,
*,
data: bytes,
headers: dict[str, str],
timeout: int,
max_redirects: int = 3,
) -> requests.Response | None:
current_url = url
for _ in range(max_redirects + 1):
response = requests.post(
current_url,
data=data,
headers=headers,
timeout=timeout,
allow_redirects=False,
)
if response.status_code not in _REDIRECT_STATUSES:
return response
location = response.headers.get("Location")
if not location:
return response
current_url = urljoin(current_url, location)
return None
+47
View File
@@ -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", "1"))
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"))
RATE_PROVIDER = os.getenv("RATE_PROVIDER", "kraken")
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")
+16
View File
@@ -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()
+9
View File
@@ -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"
+265
View File
@@ -0,0 +1,265 @@
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="wowcheckout.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 1"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ALTER COLUMN default_confirmation_target SET DEFAULT 1"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS default_qr_logo VARCHAR DEFAULT 'wownero'"
)
)
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 'btcpay_classic'"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ALTER COLUMN btcpay_checkout_style SET DEFAULT 'btcpay_classic'"
)
)
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 = 'btcpay_classic' "
"WHERE btcpay_checkout_style IS NULL"
)
)
connection.execute(
text(
"UPDATE users "
"SET btcpay_checkout_style = 'btcpay_classic' "
"WHERE btcpay_checkout_style = 'standard' "
"AND NOT EXISTS ("
" SELECT 1 FROM profile_history ph "
" WHERE ph.user_id = users.id AND ph.field_name = 'btcpay_checkout_style'"
")"
)
)
connection.execute(
text(
"UPDATE users SET default_confirmation_target = 1 "
"WHERE default_confirmation_target IS NULL"
)
)
connection.execute(
text(
"UPDATE users SET default_qr_logo = 'wownero' "
"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)
+145
View File
@@ -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="1")
default_qr_logo = Column(String, nullable=False, server_default="wownero")
default_qr_logo_data_url = Column(String, nullable=True)
btcpay_checkout_style = Column(String, nullable=False, server_default="btcpay_classic")
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())
+453
View File
@@ -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
+188
View File
@@ -0,0 +1,188 @@
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)
try:
os.chmod(storage_dir, 0o755)
except Exception:
pass
filename = f"{invoice.id}.png"
path = os.path.join(storage_dir, filename)
if os.path.exists(path):
try:
os.chmod(path, 0o644)
except Exception:
pass
return path
png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings)
_atomic_write(path, png_bytes)
try:
os.chmod(path, 0o644)
except Exception:
pass
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"wownero:{invoice.address}?{query}"
def resolve_qr_settings(invoice: Invoice) -> QrSettings:
metadata: Any = invoice.metadata_json or {}
if not isinstance(metadata, dict):
return QrSettings(logo="wownero", logo_data_url=None)
qr_meta = metadata.get("qr")
if not isinstance(qr_meta, dict):
return QrSettings(logo="wownero", 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 ("wownero", "none", "custom"):
return QrSettings(logo="wownero", 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="")
+92
View File
@@ -0,0 +1,92 @@
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
NONLOGS_MARKETS_URL = "https://api.nonlogs.io/api/markets"
KRAKEN_TICKER_URL = "https://api.kraken.com/0/public/Ticker"
RATE_SOURCE = "nonlogs"
RATE_TTL_SECONDS = 60
_cache_lock = threading.Lock()
_cached_quote: QuoteResult | None = None
_cached_at: float | None = None
_btc_cache_lock = threading.Lock()
_cached_btc_usd: Decimal | None = None
_cached_btc_at: float | None = None
BTC_CACHE_TTL = 30
@dataclass(frozen=True)
class QuoteResult:
rate: Decimal
currency: str
source: str
quoted_at: datetime
def _get_btc_usd() -> Decimal:
global _cached_btc_usd, _cached_btc_at
with _btc_cache_lock:
if _cached_btc_usd and _cached_btc_at:
if time.monotonic() - _cached_btc_at < BTC_CACHE_TTL:
return _cached_btc_usd
resp = requests.get(KRAKEN_TICKER_URL, params={"pair": "XXBTZUSD"}, timeout=5)
resp.raise_for_status()
data = resp.json()
if data.get("error"):
raise RuntimeError(f"Kraken API error: {data['error']}")
price = data["result"]["XXBTZUSD"]["c"][0]
result = Decimal(str(price))
with _btc_cache_lock:
_cached_btc_usd = result
_cached_btc_at = time.monotonic()
return result
def get_wow_rate(currency: str) -> QuoteResult:
global _cached_quote, _cached_at
normalized_currency = currency.strip().lower()
if normalized_currency != "usd":
raise ValueError(f"Unsupported fiat currency: {currency} (supported: usd)")
with _cache_lock:
if _cached_quote and _cached_at:
if time.monotonic() - _cached_at < RATE_TTL_SECONDS:
return _cached_quote
headers = {
"Accept": "application/json",
"User-Agent": "wowcheckout/1.0",
}
resp = requests.get(NONLOGS_MARKETS_URL, headers=headers, timeout=10)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
markets = data.get("markets", {})
# Use WOW-BTC price converted via Kraken BTC/USD
wow_btc = markets.get("WOW-BTC", {})
if not wow_btc.get("last_price"):
raise ValueError("Could not get WOW-BTC rate from Nonlogs")
btc_usd = _get_btc_usd()
rate = Decimal(str(wow_btc["last_price"])) * btc_usd
quote = QuoteResult(
rate=rate,
currency="USD",
source=RATE_SOURCE,
quoted_at=datetime.now(timezone.utc),
)
with _cache_lock:
_cached_quote = quote
_cached_at = time.monotonic()
return quote
+265
View File
@@ -0,0 +1,265 @@
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), "InvoicePaidInFull", 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()
+1308
View File
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
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["wownero", "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) = 1
checkout_continue_url: AnyUrl | None = Field(
default=None,
description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
)
metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field(
default=None,
description="Defaults to 60 minutes 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
@field_validator("checkout_continue_url")
@classmethod
def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
if value is None:
return None
if value.scheme == "https":
return value
if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
return value
raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
class DonationCreate(BaseModel):
amount_xmr: Decimal = Field(gt=0)
confirmation_target: conint(ge=0, le=10) = 1
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) = 1
checkout_continue_url: AnyUrl | None = Field(
default=None,
description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
)
metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field(
default=None,
description="Defaults to 60 minutes 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
@field_validator("checkout_continue_url")
@classmethod
def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
if value is None:
return None
if value.scheme == "https":
return value
if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
return value
raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
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
amount_paid_xmr: str | None = None
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
btcpay_redirect_url: str | None = None
btcpay_redirect_automatically: bool | None = None
btcpay_order_id: str | None = None
btcpay_order_number: str | None = None
checkout_continue_available: bool = False
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) = 1
default_qr_logo: QrLogoMode = "wownero"
default_qr_logo_data_url: str | None = None
btcpay_checkout_style: BtcpayCheckoutStyle = "btcpay_classic"
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
+89
View File
@@ -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"wowcheckout_{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")
+48
View File
@@ -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)
+41
View File
@@ -0,0 +1,41 @@
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
# Override monero library network constants for Wownero
from monero import const as _mc
_mc.NETS = ('main',)
_mc.MASTERADDR_NETBYTES = (53,) # Wownero mainnet address prefix
_mc.SUBADDR_NETBYTES = (63,) # Wownero subaddress prefix
_mc.INTADDRR_NETBYTES = (54,) # Wownero integrated address prefix
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))
+122
View File
@@ -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
+13
View File
@@ -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