Files
jwinterm eb15c5202a fix: include fiat amount in BTCPay webhook payload; quiet wallet-rpc spam
Two fixes:

1. _build_payload now emits amount (fiat), currency, and amount_crypto. Downstream processors like Medusa need the amount to capture the right value. Without it, Medusa was capturing 0 and leaving payments stuck 'authorized' forever despite the InvoiceSettled/InvoicePaymentSettled webhooks firing correctly at 10+ confirmations.

2. get_status() called set_daemon RPC without first opening a wallet. set_daemon requires an open wallet, so on backends holding no wallet (the common idle case with our 3-backend hash-sharded setup) it errored with RPC -13 'No wallet file' every 5 seconds, flooding logs with a dozens-per-minute stream of raw RPC error dumps from the python-monero library logger. Dropped the set_daemon call — _daemon_height() already validates daemon reachability directly and doesn't need wallet-rpc.

3. Silenced monero.backends.jsonrpc.wallet library logger at CRITICAL so expected transient -13 errors from our _ensure_wallet_open close_wallet call stop polluting the stream.
2026-04-18 16:31:32 -04:00

462 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]:
# Probe each wallet-rpc backend with get_version (works without an open
# wallet). Probe daemon reachability via direct HTTP get_height, which
# bypasses wallet-rpc. We previously also called set_daemon here as a
# "secondary daemon check", but set_daemon requires an open wallet —
# on backends that currently hold no wallet (most of the time for idle
# reconcilers) that produces a constant stream of RPC -13 "No wallet
# file" errors from the python-monero logger. The get_version +
# _daemon_height pair is sufficient and doesn't require any wallet.
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,
}
return {
"wallet_rpc": "ok",
"daemon": "ok" if daemon_height is not None else "unreachable",
"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