diff --git a/routstr/payment/price.py b/routstr/payment/price.py index ae162398..e63d6754 100644 --- a/routstr/payment/price.py +++ b/routstr/payment/price.py @@ -12,6 +12,8 @@ logger = get_logger(__name__) BTC_USD_PRICE: float | None = None SATS_USD_PRICE: float | None = None +SATS_PER_BTC = 100_000_000 + def _parse_quote(raw: object, exchange: str) -> float | None: """Coerce an exchange quote to a price, or ``None`` if it is not one. @@ -20,10 +22,12 @@ def _parse_quote(raw: object, exchange: str) -> float | None: of what it collects: an unusable quote does not merely join the sample, it *wins* it, and the result is the rate every model and every request on the node is priced at. A quote is stricter than a billable rate — it must be - positive, since a BTC price of zero is a broken feed, not free money. + positive, and positive *after* the sats conversion the node prices in: a + subnormal quote survives every guard here and still underflows to a zero + sats price, which then divides by zero on every model's rate. """ price = coerce_rate(raw) - if price is None or price <= 0: + if price is None or price <= 0 or price / SATS_PER_BTC <= 0: logger.warning( "Unusable price quote — ignoring this exchange", extra={"exchange": exchange, "quote": repr(raw)}, @@ -141,7 +145,7 @@ async def _update_prices() -> None: ) return BTC_USD_PRICE = btc_price - SATS_USD_PRICE = btc_price / 100_000_000 + SATS_USD_PRICE = btc_price / SATS_PER_BTC def btc_usd_price() -> float: diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py index f0390c9b..895fa06e 100644 --- a/tests/unit/test_pricing_rate_validation.py +++ b/tests/unit/test_pricing_rate_validation.py @@ -315,9 +315,7 @@ async def test_boolean_exchange_quote_does_not_set_the_node_price( ``float(True)`` is ``1.0``, which is finite and positive, so a payload whose price field turned into a boolean passes every numeric guard — and then - *wins* the ``min()``, pricing the whole node at one dollar per bitcoin. The - node's other coercions all reject ``bool`` before the numeric check for this - reason; this one is the exception. + *wins* the ``min()``, pricing the whole node at one dollar per bitcoin. """ from routstr.payment.price import btc_usd_price @@ -328,6 +326,25 @@ async def test_boolean_exchange_quote_does_not_set_the_node_price( assert btc_usd_price() == pytest.approx(100000.0) +@pytest.mark.asyncio +async def test_an_underflowing_exchange_quote_does_not_set_the_node_price( + refresh_price_with: Any, +) -> None: + """A quote too small to survive the sats conversion is not a price. + + ``1e-320`` is positive, so it passes the guards and wins the ``min()``, but + the node prices in sats and ``1e-320 / 100_000_000`` underflows to ``0.0`` + — a zero sats price divides by zero on every model's rate. + """ + from routstr.payment.price import btc_usd_price + + await refresh_price_with( + {"kraken": "1e-320", "coinbase": "100000.0", "binance": "100000.0"} + ) + + assert btc_usd_price() == pytest.approx(100000.0) + + @pytest.mark.asyncio async def test_an_unreadable_exchange_response_drops_only_that_quote( refresh_price_with: Any,