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
This commit is contained in:
jackstar12
2023-08-24 12:59:57 +02:00
committed by GitHub
parent 7343d1e0a0
commit e50a7fb2d1
2 changed files with 46 additions and 47 deletions

View File

@@ -1,9 +1,11 @@
import asyncio import asyncio
from typing import Callable, List, NamedTuple from typing import Callable, NamedTuple
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.cache import cache
currencies = { currencies = {
"AED": "United Arab Emirates Dirham", "AED": "United Arab Emirates Dirham",
"AFN": "Afghan Afghani", "AFN": "Afghan Afghani",
@@ -181,6 +183,19 @@ class Provider(NamedTuple):
exchange_rate_providers = { 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": Provider(
"Exir", "Exir",
"exir.io", "exir.io",
@@ -227,28 +242,6 @@ async def btc_price(currency: str) -> float:
"TO": currency.upper(), "TO": currency.upper(),
"to": currency.lower(), "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): async def fetch_price(provider: Provider):
url = provider.api_url.format(**replacements) 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 = await client.get(url, timeout=0.5)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
rate = float(provider.getter(data, replacements)) return float(provider.getter(data, replacements))
await send_channel.put(rate) except Exception as e:
except ( logger.warning(
# CoinMate returns HTTPStatus 200 but no data when a pair is not found f"Failed to fetch Bitcoin price "
TypeError, f"for {currency} from {provider.name}: {e}"
# Kraken's response dictionary doesn't include keys we look up for )
KeyError, raise
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)
asyncio.create_task(controller()) results = await asyncio.gather(
for _, provider in exchange_rate_providers.items(): *[fetch_price(provider) for provider in exchange_rate_providers.values()],
tasks.append(asyncio.create_task(fetch_price(provider))) return_exceptions=True,
)
try: rates = [r for r in results if not isinstance(r, Exception)]
await asyncio.gather(*tasks)
except asyncio.CancelledError:
pass
if not rates: if not rates:
return 9999999999 return 9999999999
elif len(rates) == 1: elif len(rates) == 1:
logger.warning("Could only fetch one Bitcoin price.") 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: 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: async def fiat_amount_as_satoshis(amount: float, currency: str) -> int:

View File

@@ -84,6 +84,19 @@ async def test_create_invoice(client, inkey_headers_to):
return invoice 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 # check POST /api/v1/payments: invoice creation for internal payments only
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_internal_invoice(client, inkey_headers_to): async def test_create_internal_invoice(client, inkey_headers_to):