mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
fix: add resilient WOW price fallback
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user