fix: add resilient WOW price fallback

This commit is contained in:
jwinterm
2026-07-16 08:59:16 -04:00
parent 531038fd1a
commit cb24f5a597
6 changed files with 246 additions and 31 deletions
+2
View File
@@ -11,6 +11,8 @@ BACKUP_RETENTION_DAYS=7
API_KEYS=dev-key-1 API_KEYS=dev-key-1
API_KEY_ENCRYPTION_KEY=replace-with-fernet-key API_KEY_ENCRYPTION_KEY=replace-with-fernet-key
RATE_PROVIDER=nonlogs RATE_PROVIDER=nonlogs
# Maximum age for a last-known-good quote when both fresh providers fail. Set 0 to fail closed.
RATE_STALE_TTL_SECONDS=300
INVOICE_RECONCILE_INTERVAL_SECONDS=30 INVOICE_RECONCILE_INTERVAL_SECONDS=30
# Founder donation wallet (view-only) # Founder donation wallet (view-only)
+10 -4
View File
@@ -57,12 +57,18 @@ Not included (by design):
## Pricing ## Pricing
WOW/USD rates are derived in two steps: WOW/USD rates use two independent paths:
1. **WOW-BTC** price from [Nonlogs](https://api.nonlogs.io/api/markets) 1. Primary: **WOW-BTC** from [Nonlogs](https://api.nonlogs.io/api/markets), converted with
2. **BTC/USD** price from [Kraken](https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD) **BTC/USD** from [Kraken](https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD).
2. Fallback: the actively traded **WOW-XMR** market from CexSwap, converted with **XMR/USD** from
Kraken. The CexSwap path requires at least `$1` of reported 24-hour USD volume.
The final rate is `WOW-BTC × BTC/USD`. Both values are cached briefly (60s for WOW-BTC, 30s for BTC/USD) to avoid excessive API calls. Fresh quotes are cached for 60 seconds. If both providers fail, the API may use the last-known-good
quote for at most `RATE_STALE_TTL_SECONDS` (default 300); the invoice records a `:stale` source suffix
and the original quote time. Set the value to `0` to fail closed instead. Every fresh or cached quote
must be at least `$0.001` per WOW; lower values are rejected and invoice creation fails gracefully.
Set `RATE_PROVIDER=cexswap` to prefer CexSwap while retaining Nonlogs+Kraken as fallback.
## Differences from xmrcheckout ## Differences from xmrcheckout
+1 -1
View File
@@ -29,7 +29,7 @@ INVOICE_DEFAULT_EXPIRY_HOURS = int(os.getenv("INVOICE_DEFAULT_EXPIRY_HOURS", "1"
LATE_PAYMENT_LOOKBACK_HOURS = int(os.getenv("LATE_PAYMENT_LOOKBACK_HOURS", "48")) LATE_PAYMENT_LOOKBACK_HOURS = int(os.getenv("LATE_PAYMENT_LOOKBACK_HOURS", "48"))
DONATION_EXPIRY_MINUTES = int(os.getenv("DONATION_EXPIRY_MINUTES", "30")) DONATION_EXPIRY_MINUTES = int(os.getenv("DONATION_EXPIRY_MINUTES", "30"))
DONATION_ACTIVE_INVOICE_LIMIT = int(os.getenv("DONATION_ACTIVE_INVOICE_LIMIT", "25")) DONATION_ACTIVE_INVOICE_LIMIT = int(os.getenv("DONATION_ACTIVE_INVOICE_LIMIT", "25"))
RATE_PROVIDER = os.getenv("RATE_PROVIDER", "kraken") RATE_PROVIDER = os.getenv("RATE_PROVIDER", "nonlogs")
DONATIONS_ENABLED = _get_bool_env("DONATIONS_ENABLED", False) DONATIONS_ENABLED = _get_bool_env("DONATIONS_ENABLED", False)
OPEN_REGISTRATION = _get_bool_env("OPEN_REGISTRATION", True) OPEN_REGISTRATION = _get_bool_env("OPEN_REGISTRATION", True)
+104 -25
View File
@@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
import logging
import os
import threading import threading
import time import time
from typing import Any from typing import Any
@@ -11,8 +14,14 @@ import requests
NONLOGS_MARKETS_URL = "https://api.nonlogs.io/api/markets" NONLOGS_MARKETS_URL = "https://api.nonlogs.io/api/markets"
KRAKEN_TICKER_URL = "https://api.kraken.com/0/public/Ticker" KRAKEN_TICKER_URL = "https://api.kraken.com/0/public/Ticker"
RATE_SOURCE = "nonlogs" CEXSWAP_MARKETS_URL = "https://cexswap.cc/api/public/markets/summary"
RATE_PROVIDER = os.getenv("RATE_PROVIDER", "nonlogs").strip().lower()
RATE_TTL_SECONDS = 60 RATE_TTL_SECONDS = 60
RATE_STALE_TTL_SECONDS = max(0, int(os.getenv("RATE_STALE_TTL_SECONDS", "300")))
MIN_WOW_USD_RATE = Decimal("0.001")
CEXSWAP_MIN_VOLUME_USD = Decimal("1")
logger = logging.getLogger(__name__)
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
_cached_quote: QuoteResult | None = None _cached_quote: QuoteResult | None = None
@@ -52,17 +61,7 @@ def _get_btc_usd() -> Decimal:
return result return result
def get_wow_rate(currency: str) -> QuoteResult: def _get_nonlogs_usd() -> Decimal:
global _cached_quote, _cached_at
normalized_currency = currency.strip().lower()
if normalized_currency != "usd":
raise ValueError(f"Unsupported fiat currency: {currency} (supported: usd)")
with _cache_lock:
if _cached_quote and _cached_at:
if time.monotonic() - _cached_at < RATE_TTL_SECONDS:
return _cached_quote
headers = { headers = {
"Accept": "application/json", "Accept": "application/json",
"User-Agent": "wowcheckout/1.0", "User-Agent": "wowcheckout/1.0",
@@ -71,22 +70,102 @@ def get_wow_rate(currency: str) -> QuoteResult:
resp.raise_for_status() resp.raise_for_status()
data: dict[str, Any] = resp.json() data: dict[str, Any] = resp.json()
markets = data.get("markets", {}) markets = data.get("markets", {})
wow_btc = markets.get("WOW-BTC", {}) if isinstance(markets, dict) else {}
# Use WOW-BTC price converted via Kraken BTC/USD
wow_btc = markets.get("WOW-BTC", {})
if not wow_btc.get("last_price"): if not wow_btc.get("last_price"):
raise ValueError("Could not get WOW-BTC rate from Nonlogs") raise ValueError("Could not get WOW-BTC rate from Nonlogs")
return Decimal(str(wow_btc["last_price"])) * _get_btc_usd()
btc_usd = _get_btc_usd()
rate = Decimal(str(wow_btc["last_price"])) * btc_usd
quote = QuoteResult( def _get_xmr_usd() -> Decimal:
rate=rate, resp = requests.get(KRAKEN_TICKER_URL, params={"pair": "XXMRZUSD"}, timeout=5)
currency="USD", resp.raise_for_status()
source=RATE_SOURCE, data = resp.json()
quoted_at=datetime.now(timezone.utc), if data.get("error"):
raise RuntimeError(f"Kraken API error: {data['error']}")
return Decimal(str(data["result"]["XXMRZUSD"]["c"][0]))
def _get_cexswap_usd() -> Decimal:
headers = {
"Accept": "application/json",
"User-Agent": "wowcheckout/1.0",
}
resp = requests.get(CEXSWAP_MARKETS_URL, headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
markets = data.get("items", []) if isinstance(data, dict) else data
market = next(
(
item
for item in markets
if isinstance(item, dict) and str(item.get("pair", "")).upper() == "WOW-XMR"
),
None,
) )
if not market:
raise ValueError("CexSwap did not return the WOW-XMR market")
volume_usd = Decimal(str(market.get("volume24h_usd", "0")))
if volume_usd < CEXSWAP_MIN_VOLUME_USD:
raise ValueError("CexSwap WOW-XMR market has insufficient 24-hour volume")
wow_xmr = Decimal(str(market.get("last", "0")))
return wow_xmr * _get_xmr_usd()
def _providers() -> list[tuple[str, Callable[[], Decimal]]]:
providers = [
("nonlogs+kraken", _get_nonlogs_usd),
("cexswap+kraken", _get_cexswap_usd),
]
if RATE_PROVIDER == "cexswap":
providers.reverse()
return providers
def get_wow_rate(currency: str) -> QuoteResult:
global _cached_quote, _cached_at
normalized_currency = currency.strip().lower()
if normalized_currency != "usd":
raise ValueError(f"Unsupported fiat currency: {currency} (supported: usd)")
with _cache_lock: with _cache_lock:
_cached_quote = quote if _cached_quote and _cached_at:
_cached_at = time.monotonic() if (
return quote time.monotonic() - _cached_at < RATE_TTL_SECONDS
and _cached_quote.rate >= MIN_WOW_USD_RATE
):
return _cached_quote
for source, provider in _providers():
try:
rate = provider()
if rate < MIN_WOW_USD_RATE:
raise ValueError(
f"{source} returned Wownero below the ${MIN_WOW_USD_RATE} safety floor"
)
quote = QuoteResult(
rate=rate,
currency="USD",
source=source,
quoted_at=datetime.now(timezone.utc),
)
with _cache_lock:
_cached_quote = quote
_cached_at = time.monotonic()
return quote
except (KeyError, TypeError, ValueError, RuntimeError, requests.RequestException) as error:
logger.warning("Wownero rate provider %s failed: %s", source, error)
with _cache_lock:
if _cached_quote and _cached_at and RATE_STALE_TTL_SECONDS > 0:
if (
time.monotonic() - _cached_at < RATE_STALE_TTL_SECONDS
and _cached_quote.rate >= MIN_WOW_USD_RATE
):
return QuoteResult(
rate=_cached_quote.rate,
currency=_cached_quote.currency,
source=f"{_cached_quote.source}:stale",
quoted_at=_cached_quote.quoted_at,
)
raise RuntimeError("Wownero rate providers unavailable")
+127
View File
@@ -0,0 +1,127 @@
import time
import unittest
from datetime import datetime, timezone
from decimal import Decimal
from unittest.mock import Mock, patch
import requests
from app import rates
def response(payload):
result = Mock()
result.raise_for_status.return_value = None
result.json.return_value = payload
return result
class WowneroRateTests(unittest.TestCase):
def setUp(self):
rates._cached_quote = None
rates._cached_at = None
rates._cached_btc_usd = None
rates._cached_btc_at = None
rates.RATE_PROVIDER = "nonlogs"
@patch("app.rates.requests.get")
def test_uses_nonlogs_and_kraken_as_primary_quote(self, get):
get.side_effect = [
response({"markets": {"WOW-BTC": {"last_price": "0.000001"}}}),
response({"error": [], "result": {"XXBTZUSD": {"c": ["60000"]}}}),
]
quote = rates.get_wow_rate("USD")
self.assertEqual(quote.rate, Decimal("0.060000"))
self.assertEqual(quote.source, "nonlogs+kraken")
@patch("app.rates.requests.get")
def test_falls_back_to_a_traded_cexswap_market(self, get):
get.side_effect = [
requests.ConnectionError("nonlogs unavailable"),
response(
{
"items": [
{
"pair": "WOW-XMR",
"last": "0.00002",
"volume24h_usd": "10",
}
]
}
),
response({"error": [], "result": {"XXMRZUSD": {"c": ["300"]}}}),
]
quote = rates.get_wow_rate("usd")
self.assertEqual(quote.rate, Decimal("0.00600"))
self.assertEqual(quote.source, "cexswap+kraken")
@patch("app.rates.requests.get")
def test_rejects_quotes_below_the_wownero_safety_floor(self, get):
get.side_effect = [
response({"markets": {"WOW-BTC": {"last_price": "0.000000001"}}}),
response({"error": [], "result": {"XXBTZUSD": {"c": ["100"]}}}),
response(
{
"items": [
{
"pair": "WOW-XMR",
"last": "0.000001",
"volume24h_usd": "10",
}
]
}
),
response({"error": [], "result": {"XXMRZUSD": {"c": ["100"]}}}),
]
with self.assertRaisesRegex(RuntimeError, "rate providers unavailable"):
rates.get_wow_rate("usd")
@patch("app.rates.requests.get", side_effect=requests.ConnectionError("offline"))
def test_marks_a_bounded_last_known_good_quote_as_stale(self, _get):
rates._cached_quote = rates.QuoteResult(
rate=Decimal("0.04"),
currency="USD",
source="cexswap+kraken",
quoted_at=datetime.now(timezone.utc),
)
rates._cached_at = time.monotonic() - rates.RATE_TTL_SECONDS - 1
quote = rates.get_wow_rate("usd")
self.assertEqual(quote.rate, Decimal("0.04"))
self.assertEqual(quote.source, "cexswap+kraken:stale")
@patch("app.rates.requests.get", side_effect=requests.ConnectionError("offline"))
def test_fails_closed_when_the_cached_quote_is_too_old(self, _get):
rates._cached_quote = rates.QuoteResult(
rate=Decimal("0.04"),
currency="USD",
source="cexswap+kraken",
quoted_at=datetime.now(timezone.utc),
)
rates._cached_at = time.monotonic() - rates.RATE_STALE_TTL_SECONDS - 1
with self.assertRaisesRegex(RuntimeError, "rate providers unavailable"):
rates.get_wow_rate("usd")
@patch("app.rates.requests.get", side_effect=requests.ConnectionError("offline"))
def test_rejects_a_stale_quote_below_the_safety_floor(self, _get):
rates._cached_quote = rates.QuoteResult(
rate=Decimal("0.0009"),
currency="USD",
source="cexswap+kraken",
quoted_at=datetime.now(timezone.utc),
)
rates._cached_at = time.monotonic() - rates.RATE_TTL_SECONDS - 1
with self.assertRaisesRegex(RuntimeError, "rate providers unavailable"):
rates.get_wow_rate("usd")
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -20,7 +20,8 @@ services:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
API_KEYS: ${API_KEYS} API_KEYS: ${API_KEYS}
API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY} API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY}
RATE_PROVIDER: ${RATE_PROVIDER:-kraken} RATE_PROVIDER: ${RATE_PROVIDER:-nonlogs}
RATE_STALE_TTL_SECONDS: ${RATE_STALE_TTL_SECONDS:-300}
FOUNDER_PAYMENT_ADDRESS: ${FOUNDER_PAYMENT_ADDRESS} FOUNDER_PAYMENT_ADDRESS: ${FOUNDER_PAYMENT_ADDRESS}
FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY} FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY}
DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false} DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}