fix(pricing): reject an exchange quote that underflows the sats price

A quote of `1e-320` is finite and positive, so it passed the feed's guards and
then won the `min()` — but the node prices in sats, and `1e-320 / 100_000_000`
is `0.0`. A zero sats price divides by zero on every model's rate, so one
malformed feed could take the node's pricing down while two healthy quotes
stood beside it.

Require the quote to survive the conversion it is going to be put through, and
name the divisor while it has two call sites.

Also drops a line from the boolean-quote test that said this coercion was the
only one rejecting booleans; they all share one now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014X8RZzzbAuQCbavhFjTvJ4
This commit is contained in:
Jeroen Ubbink
2026-08-26 15:50:26 +02:00
co-authored by Claude Opus 5
parent 337be4895e
commit 027bb3da31
2 changed files with 27 additions and 6 deletions
+7 -3
View File
@@ -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:
+20 -3
View File
@@ -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,