mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
fix: reconciler hardening, decimal formatting, webhook retries
- 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).
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
@@ -142,8 +142,12 @@ def _resolve_btcpay_amount(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Fiat quote service unavailable",
|
||||
) from exc
|
||||
# Round UP to 3 decimal places so we never undercharge.
|
||||
# 3 decimals = 0.001 WOW — more than enough precision for Wownero.
|
||||
# See also: routes.py _resolve_invoice_amount() for the same rounding.
|
||||
# To change precision: adjust the Decimal quantize string here and in routes.py.
|
||||
amount_xmr = (Decimal(amount) / quote.rate).quantize(
|
||||
Decimal("0.000000000001"), rounding=ROUND_DOWN
|
||||
Decimal("0.001"), rounding=ROUND_UP
|
||||
)
|
||||
quote_payload = {
|
||||
"fiat_amount": str(amount),
|
||||
@@ -213,7 +217,11 @@ def _btcpay_amount_currency(invoice: Invoice) -> tuple[str, str, dict[str, Any]]
|
||||
|
||||
|
||||
def _format_xmr_fixed(value: Decimal) -> str:
|
||||
return f"{value.quantize(Decimal('0.000000000001')):.12f}"
|
||||
"""Format WOW amount with trailing zeros stripped."""
|
||||
formatted = f"{value.quantize(Decimal('0.000000000001')):.12f}"
|
||||
if "." in formatted:
|
||||
formatted = formatted.rstrip("0").rstrip(".")
|
||||
return formatted
|
||||
|
||||
|
||||
def _atomic_to_xmr(value: int) -> Decimal:
|
||||
|
||||
+32
-16
@@ -56,33 +56,49 @@ def dispatch_btcpay_webhooks(
|
||||
"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:
|
||||
# Retry up to 3 times with backoff on transient failures
|
||||
last_response = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
last_response = _post_with_redirects(
|
||||
hook.url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if last_response is not None and last_response.status_code < 500:
|
||||
break
|
||||
except RequestException:
|
||||
if attempt == 2:
|
||||
raise
|
||||
if attempt < 2:
|
||||
time.sleep(1.0 * (attempt + 1))
|
||||
if last_response is None:
|
||||
logger.warning(
|
||||
"BTCPay webhook delivery failed after retries (redirect loop)",
|
||||
extra={"webhook_id": str(hook.id), "event": event_type, "url": hook.url},
|
||||
)
|
||||
continue
|
||||
if response.status_code >= 400:
|
||||
if last_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,
|
||||
"http_status": last_response.status_code,
|
||||
},
|
||||
)
|
||||
except RequestException as exc:
|
||||
logger.warning(
|
||||
"BTCPay webhook delivery failed",
|
||||
extra={"webhook_id": str(hook.id), "event": event_type},
|
||||
logger.error(
|
||||
"BTCPay webhook delivery failed after 3 attempts: %s",
|
||||
exc,
|
||||
extra={"webhook_id": str(hook.id), "event": event_type, "url": hook.url},
|
||||
)
|
||||
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)},
|
||||
logger.error(
|
||||
"BTCPay webhook dispatch failed: %s",
|
||||
exc,
|
||||
extra={"webhook_id": str(hook.id), "event": event_type},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -380,7 +380,9 @@ class MoneroWalletService:
|
||||
)
|
||||
except (RPCError, RequestException):
|
||||
try:
|
||||
restore_height = self._daemon_height() or 0
|
||||
# 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(
|
||||
|
||||
+108
-102
@@ -96,116 +96,120 @@ def _reconcile_invoices(service: MoneroWalletService) -> None:
|
||||
transfers = _get_transfers_with_retry(
|
||||
service, user, 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)
|
||||
db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id})
|
||||
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:
|
||||
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(
|
||||
"Payment not yet detected",
|
||||
"Invoice totals",
|
||||
extra={
|
||||
"invoice_id": str(invoice.id),
|
||||
"required_atomic": required_atomic,
|
||||
"received_atomic": total_atomic,
|
||||
"confirmations": max_confirmations,
|
||||
},
|
||||
)
|
||||
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"
|
||||
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()
|
||||
dispatch_webhooks(db, str(user.id), "invoice.expired", invoice)
|
||||
dispatch_btcpay_webhooks(
|
||||
db, str(user.id), "InvoiceExpired", invoice
|
||||
)
|
||||
continue
|
||||
required_atomic = _xmr_to_atomic(invoice.amount_xmr)
|
||||
is_paid = total_atomic >= required_atomic
|
||||
|
||||
if is_paid and invoice.status in ("pending", "expired"):
|
||||
logger.info(
|
||||
"Invoice marked payment detected",
|
||||
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)},
|
||||
)
|
||||
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
|
||||
)
|
||||
db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id})
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -273,10 +277,12 @@ def _sync_invoice_transfers(
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
+6
-2
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_UP, InvalidOperation
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
@@ -342,8 +342,12 @@ def _resolve_invoice_amount(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Fiat quote service unavailable",
|
||||
) from exc
|
||||
# Round UP to 3 decimal places so we never undercharge.
|
||||
# 3 decimals = 0.001 WOW — more than enough precision for Wownero.
|
||||
# See also: btcpay_routes.py for the same rounding.
|
||||
# To change precision: adjust the Decimal quantize string here and in btcpay_routes.py.
|
||||
amount_xmr = (Decimal(requested_amount_fiat) / quote.rate).quantize(
|
||||
Decimal("0.000000000001"), rounding=ROUND_DOWN
|
||||
Decimal("0.001"), rounding=ROUND_UP
|
||||
)
|
||||
warnings = ["Fiat conversion is an estimate and does not lock a rate."]
|
||||
quote_payload = {
|
||||
|
||||
Reference in New Issue
Block a user