From e50a7fb2d1291e0064d591517721143840252a26 Mon Sep 17 00:00:00 2001 From: jackstar12 <62219658+jackstar12@users.noreply.github.com> Date: Thu, 24 Aug 2023 12:59:57 +0200 Subject: [PATCH] refactor exchange rates (#1847) * simplify and cache exchange rate note that exir was removed as a provider * add binance as provider * log exception * add test * add blockchain.com provider --- lnbits/utils/exchange_rates.py | 80 ++++++++++++++-------------------- tests/core/views/test_api.py | 13 ++++++ 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py index b1ceb26fd..52b1d4ac9 100644 --- a/lnbits/utils/exchange_rates.py +++ b/lnbits/utils/exchange_rates.py @@ -1,9 +1,11 @@ import asyncio -from typing import Callable, List, NamedTuple +from typing import Callable, NamedTuple import httpx from loguru import logger +from lnbits.cache import cache + currencies = { "AED": "United Arab Emirates Dirham", "AFN": "Afghan Afghani", @@ -181,6 +183,19 @@ class Provider(NamedTuple): exchange_rate_providers = { + # https://binance-docs.github.io/apidocs/spot/en/#symbol-price-ticker + "binance": Provider( + "Binance", + "binance.com", + "https://api.binance.com/api/v3/ticker/price?symbol={FROM}{TO}", + lambda data, replacements: data["price"], + ), + "blockchain": Provider( + "Blockchain", + "blockchain.com", + "https://blockchain.info/tobtc?currency={TO}&value=1", + lambda data, replacements: 1 / data, + ), "exir": Provider( "Exir", "exir.io", @@ -227,28 +242,6 @@ async def btc_price(currency: str) -> float: "TO": currency.upper(), "to": currency.lower(), } - rates: List[float] = [] - tasks: List[asyncio.Task] = [] - - send_channel: asyncio.Queue = asyncio.Queue() - - async def controller(): - failures = 0 - while True: - rate = await send_channel.get() - if rate: - rates.append(rate) - else: - failures += 1 - - if len(rates) >= 2 or len(rates) == 1 and failures >= 2: - for t in tasks: - t.cancel() - break - if failures == len(exchange_rate_providers): - for t in tasks: - t.cancel() - break async def fetch_price(provider: Provider): url = provider.api_url.format(**replacements) @@ -257,40 +250,33 @@ async def btc_price(currency: str) -> float: r = await client.get(url, timeout=0.5) r.raise_for_status() data = r.json() - rate = float(provider.getter(data, replacements)) - await send_channel.put(rate) - except ( - # CoinMate returns HTTPStatus 200 but no data when a pair is not found - TypeError, - # Kraken's response dictionary doesn't include keys we look up for - KeyError, - httpx.ConnectTimeout, - httpx.ConnectError, - httpx.ReadTimeout, - # Some providers throw a 404 when a currency pair is not found - httpx.HTTPStatusError, - ): - await send_channel.put(None) + return float(provider.getter(data, replacements)) + except Exception as e: + logger.warning( + f"Failed to fetch Bitcoin price " + f"for {currency} from {provider.name}: {e}" + ) + raise - asyncio.create_task(controller()) - for _, provider in exchange_rate_providers.items(): - tasks.append(asyncio.create_task(fetch_price(provider))) - - try: - await asyncio.gather(*tasks) - except asyncio.CancelledError: - pass + results = await asyncio.gather( + *[fetch_price(provider) for provider in exchange_rate_providers.values()], + return_exceptions=True, + ) + rates = [r for r in results if not isinstance(r, Exception)] if not rates: return 9999999999 elif len(rates) == 1: logger.warning("Could only fetch one Bitcoin price.") - return sum([rate for rate in rates]) / len(rates) + return sum(rates) / len(rates) async def get_fiat_rate_satoshis(currency: str) -> float: - return float(100_000_000 / (await btc_price(currency))) + price = await cache.save_result( + lambda: btc_price(currency), f"btc-price-{currency}" + ) + return float(100_000_000 / price) async def fiat_amount_as_satoshis(amount: float, currency: str) -> int: diff --git a/tests/core/views/test_api.py b/tests/core/views/test_api.py index 009b71e50..0546409a1 100644 --- a/tests/core/views/test_api.py +++ b/tests/core/views/test_api.py @@ -84,6 +84,19 @@ async def test_create_invoice(client, inkey_headers_to): return invoice +@pytest.mark.asyncio +async def test_create_invoice_fiat_amount(client, inkey_headers_to): + data = await get_random_invoice_data() + data["unit"] = "EUR" + response = await client.post( + "/api/v1/payments", json=data, headers=inkey_headers_to + ) + assert response.status_code == 201 + invoice = response.json() + decode = bolt11.decode(invoice["payment_request"]) + assert decode.amount_msat != data["amount"] * 1000 + + # check POST /api/v1/payments: invoice creation for internal payments only @pytest.mark.asyncio async def test_create_internal_invoice(client, inkey_headers_to):