Files
wowcheckout/api/app/reconciler.py
T
jwinterm f9159f5877 fix: Wownero atomic unit is 10^11, not 10^12
The codebase was forked from xmrcheckout and kept Monero's 10^12 atomic-unit constant everywhere. Wownero uses 11 decimal places, not 12.

Effect: _xmr_to_atomic(amount) produced a required_atomic value 10× larger than what wallet-rpc actually reports for the subaddress. Invoices flagged total_paid_atomic correctly (that comes from the daemon) but is_paid = total_paid >= required was always false, so the reconciler never flipped status pending → payment_detected, webhook never fired, Medusa never saw the payment.

Fix all four places: reconciler._xmr_to_atomic, btcpay_routes._xmr_to_atomic, btcpay_routes._atomic_to_xmr, btcpay_routes._format_xmr_fixed, routes.py invoice-status response. Extracted a WOW_ATOMIC_UNITS constant so this doesn't get lost again when we touch these files.
2026-04-19 15:51:45 -04:00

437 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_, text
from sqlalchemy.exc import SQLAlchemyError
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, SystemStatus, User
from .monero_service import MoneroWalletService, TransferDetail
from .webhooks import dispatch_webhooks
logger = logging.getLogger(__name__)
MONERO_CONNECTIVITY_STATUS_NAME = "monero_connectivity"
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)
# Silence expected RPC -13 ("No wallet file") spam from the python-monero
# library. Those happen routinely when close_wallet is called on an idle
# backend (by _ensure_wallet_open) and are handled by our retry logic;
# they are not actionable errors and were drowning real log output.
# Real wallet-rpc problems still bubble up as exceptions which we log
# explicitly through logger.error / logger.exception.
logging.getLogger("monero.backends.jsonrpc.wallet").setLevel(logging.CRITICAL)
while True:
status_db: Session | None = None
try:
status_db = SessionLocal()
service = MoneroWalletService()
_safe_update_monero_connectivity_status(status_db, service)
_safe_update_reconciler_status(
status_db,
started_at=datetime.now(timezone.utc),
completed_at=None,
error_message=None,
)
_reconcile_invoices(service)
_safe_update_reconciler_status(
status_db,
completed_at=datetime.now(timezone.utc),
error_message=None,
)
except Exception as exc:
logger.exception("Invoice reconcile failed: %s", exc)
if status_db is None:
status_db = SessionLocal()
_safe_update_monero_connectivity_error(status_db)
_safe_update_reconciler_status(
status_db,
error_message=str(exc),
)
finally:
if status_db is not None:
status_db.close()
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:
# Advisory lock prevents duplicate reconciliation during deploys
lock_id = hash(str(invoice.id)) & 0x7FFFFFFF
acquired = db.execute(
text("SELECT pg_try_advisory_lock(:id)"),
{"id": lock_id},
).scalar()
if not acquired:
continue
try:
transfers = _get_transfers_with_retry(
service, user, invoice.address,
)
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
)
except Exception as exc:
logger.error(
"Failed to reconcile invoice: %s",
exc,
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
)
try:
db.rollback()
except Exception:
pass
finally:
db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id})
finally:
db.close()
def _get_transfers_with_retry(
service: MoneroWalletService,
user: User,
address: str,
retries: int = 3,
) -> list[TransferDetail]:
"""Call get_transfers_for_address with retry + backoff on RPC errors."""
delays = [0.5, 1.0, 2.0]
last_exc: Exception | None = None
for attempt in range(retries):
try:
return service.get_transfers_for_address(user=user, address=address)
except Exception as exc:
last_exc = exc
if attempt < retries - 1:
time.sleep(delays[attempt])
raise last_exc # type: ignore[misc]
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
# Only delete transfers if we got data back — don't wipe records on RPC glitch
if transfers:
for stored in existing:
if stored.txid not in seen_txids:
db.delete(stored)
changed = True
return changed
def _update_reconciler_status(
db: Session,
*,
started_at: datetime | None = None,
completed_at: datetime | None = None,
error_message: str | None = None,
) -> None:
status_row = db.query(SystemStatus).filter(SystemStatus.name == "reconciler").first()
if status_row is None:
status_row = SystemStatus(name="reconciler")
if started_at is not None:
status_row.last_reconcile_started_at = started_at
if completed_at is not None:
status_row.last_reconcile_completed_at = completed_at
status_row.last_reconcile_error = error_message
db.add(status_row)
db.commit()
def _update_monero_connectivity_status(
db: Session,
*,
wallet_rpc: str,
daemon: str,
daemon_height: int | None,
) -> None:
status_row = (
db.query(SystemStatus)
.filter(SystemStatus.name == MONERO_CONNECTIVITY_STATUS_NAME)
.first()
)
if status_row is None:
status_row = SystemStatus(name=MONERO_CONNECTIVITY_STATUS_NAME)
status_row.wallet_rpc = wallet_rpc
status_row.daemon = daemon
status_row.daemon_height = daemon_height
status_row.checked_at = datetime.now(timezone.utc)
db.add(status_row)
db.commit()
def _safe_update_monero_connectivity_status(
db: Session,
service: MoneroWalletService,
) -> None:
try:
status_payload = service.get_status()
_update_monero_connectivity_status(
db,
wallet_rpc=str(status_payload.get("wallet_rpc", "unreachable")),
daemon=str(status_payload.get("daemon", "unknown")),
daemon_height=(
int(status_payload["daemon_height"])
if isinstance(status_payload.get("daemon_height"), int)
else None
),
)
except Exception:
db.rollback()
logger.warning("Unable to persist Monero connectivity status", exc_info=True)
def _safe_update_monero_connectivity_error(db: Session) -> None:
try:
_update_monero_connectivity_status(
db,
wallet_rpc="unreachable",
daemon="unknown",
daemon_height=None,
)
except SQLAlchemyError:
db.rollback()
logger.warning("Unable to persist Monero connectivity fallback", exc_info=True)
def _safe_update_reconciler_status(
db: Session,
*,
started_at: datetime | None = None,
completed_at: datetime | None = None,
error_message: str | None = None,
) -> None:
try:
_update_reconciler_status(
db,
started_at=started_at,
completed_at=completed_at,
error_message=error_message,
)
except SQLAlchemyError:
db.rollback()
logger.warning("Unable to persist reconciler status heartbeat", exc_info=True)
# Wownero uses 11 decimal places (atomic unit = 10^-11 WOW), NOT 12 like
# Monero. Using 10^12 here makes required_atomic 10× larger than actual paid
# amount, so is_paid never becomes true and invoices stay pending forever
# even after detection + confirmations. This entire codebase was forked from
# xmrcheckout and the constant must be corrected wherever it appears.
WOW_ATOMIC_UNITS = Decimal("100000000000") # 10^11
def _xmr_to_atomic(amount: Decimal) -> int:
quantized = (Decimal(amount) * WOW_ATOMIC_UNITS).to_integral_value(
rounding=ROUND_DOWN
)
return int(quantized)
if __name__ == "__main__":
main()