From cb24f5a59774c157f554130b0bcf0ea2b950e60d Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 16 Jul 2026 08:56:12 -0400 Subject: [PATCH] fix: add resilient WOW price fallback --- .env.example | 2 + README.md | 14 +++-- api/app/config.py | 2 +- api/app/rates.py | 129 ++++++++++++++++++++++++++++++++-------- api/tests/test_rates.py | 127 +++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 3 +- 6 files changed, 246 insertions(+), 31 deletions(-) create mode 100644 api/tests/test_rates.py diff --git a/.env.example b/.env.example index 0492986..e6ff270 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ BACKUP_RETENTION_DAYS=7 API_KEYS=dev-key-1 API_KEY_ENCRYPTION_KEY=replace-with-fernet-key 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 # Founder donation wallet (view-only) diff --git a/README.md b/README.md index c3229cc..dfb294a 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,18 @@ Not included (by design): ## 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) -2. **BTC/USD** price from [Kraken](https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD) +1. Primary: **WOW-BTC** from [Nonlogs](https://api.nonlogs.io/api/markets), converted with + **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 diff --git a/api/app/config.py b/api/app/config.py index 579633a..e718b6e 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -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")) DONATION_EXPIRY_MINUTES = int(os.getenv("DONATION_EXPIRY_MINUTES", "30")) 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) OPEN_REGISTRATION = _get_bool_env("OPEN_REGISTRATION", True) diff --git a/api/app/rates.py b/api/app/rates.py index 86e96f4..344824e 100644 --- a/api/app/rates.py +++ b/api/app/rates.py @@ -1,8 +1,11 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timezone from decimal import Decimal +import logging +import os import threading import time from typing import Any @@ -11,8 +14,14 @@ import requests NONLOGS_MARKETS_URL = "https://api.nonlogs.io/api/markets" 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_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() _cached_quote: QuoteResult | None = None @@ -52,17 +61,7 @@ def _get_btc_usd() -> Decimal: return result -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: - if _cached_quote and _cached_at: - if time.monotonic() - _cached_at < RATE_TTL_SECONDS: - return _cached_quote - +def _get_nonlogs_usd() -> Decimal: headers = { "Accept": "application/json", "User-Agent": "wowcheckout/1.0", @@ -71,22 +70,102 @@ def get_wow_rate(currency: str) -> QuoteResult: resp.raise_for_status() data: dict[str, Any] = resp.json() markets = data.get("markets", {}) - - # Use WOW-BTC price converted via Kraken BTC/USD - wow_btc = markets.get("WOW-BTC", {}) + wow_btc = markets.get("WOW-BTC", {}) if isinstance(markets, dict) else {} if not wow_btc.get("last_price"): 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( - rate=rate, - currency="USD", - source=RATE_SOURCE, - quoted_at=datetime.now(timezone.utc), +def _get_xmr_usd() -> Decimal: + resp = requests.get(KRAKEN_TICKER_URL, params={"pair": "XXMRZUSD"}, timeout=5) + resp.raise_for_status() + data = resp.json() + 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: - _cached_quote = quote - _cached_at = time.monotonic() - return quote + if _cached_quote and _cached_at: + if ( + 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") diff --git a/api/tests/test_rates.py b/api/tests/test_rates.py new file mode 100644 index 0000000..4d94cdc --- /dev/null +++ b/api/tests/test_rates.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml index e218681..7656a81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,8 @@ services: DATABASE_URL: ${DATABASE_URL} API_KEYS: ${API_KEYS} 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_VIEW_KEY: ${FOUNDER_VIEW_KEY} DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}