mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
- Advisory lock: wrap per-invoice processing in try/finally so lock is ALWAYS released, even on exception. Previously, any error between lock acquisition and release would leak the lock, causing the invoice to be silently skipped forever on subsequent cycles. - Error logging: wallet RPC errors now logged at ERROR level (was DEBUG), making silent reconciliation failures visible in production logs. - Transfer protection: don't delete stored transfers when wallet-rpc returns an empty response (transient RPC glitch). Only prune when we actually got transfer data back. - Wallet restore height: new wallets generated from keys now look back ~2 days (1440 blocks) instead of starting at current height. Prevents missing payments that arrived before the wallet was first opened on a given wallet-rpc instance. - Decimal formatting: _format_xmr_fixed() now strips trailing zeros instead of always showing 12 decimal places. - Invoice amount rounding: fiat-converted amounts now use ROUND_UP with 3 decimal places (was ROUND_DOWN with 12), preventing undercharging. - Webhook retries: BTCPay webhook delivery now retries up to 3 times with 1s/2s backoff on 5xx or network errors. Timeout increased 5s→10s. Failures logged at ERROR level (was WARNING/DEBUG).
456 lines
17 KiB
Python
456 lines
17 KiB
Python
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:
|
|
# Look back ~2 days so new wallets can find recent payments
|
|
daemon_height = self._daemon_height() or 0
|
|
restore_height = max(0, daemon_height - 1440)
|
|
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
|