mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
Ports xmrcheckout upstream features (7 commits since 655d037): - SystemStatus model for reconciler heartbeat tracking - Monero connectivity caching in system status - Health status endpoints in routes - Hardened reconciler status updates with SQLAlchemyError handling - Status schema additions Adapted for Wownero: 3-decimal precision (0.001 WOW) in amount rounding.
469 lines
18 KiB
Python
469 lines
18 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 | int | None]:
|
|
daemon_height = self._daemon_height()
|
|
for backend in self._backends:
|
|
try:
|
|
backend.client.raw_request("get_version")
|
|
except Exception:
|
|
return {
|
|
"wallet_rpc": "unreachable",
|
|
"daemon": "ok" if daemon_height is not None else "unreachable",
|
|
"daemon_height": daemon_height,
|
|
}
|
|
|
|
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",
|
|
"daemon_height": daemon_height,
|
|
}
|
|
return {
|
|
"wallet_rpc": "ok",
|
|
"daemon": "ok" if self._daemon_address else "unknown",
|
|
"daemon_height": daemon_height,
|
|
}
|
|
|
|
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
|