diff --git a/lnbits/extensions/lnurlp/helpers.py b/lnbits/extensions/lnurlp/helpers.py deleted file mode 100644 index 13e74857..00000000 --- a/lnbits/extensions/lnurlp/helpers.py +++ /dev/null @@ -1,48 +0,0 @@ -import trio # type: ignore -import httpx - - -async def get_fiat_rate(currency: str): - assert currency == "USD", "Only USD is supported as fiat currency." - return await get_usd_rate() - - -async def get_usd_rate(): - """ - Returns an average satoshi price from multiple sources. - """ - - satoshi_prices = [None, None, None] - - async def fetch_price(index, url, getter): - try: - async with httpx.AsyncClient() as client: - r = await client.get(url) - r.raise_for_status() - satoshi_price = int(100_000_000 / float(getter(r.json()))) - satoshi_prices[index] = satoshi_price - except Exception: - pass - - async with trio.open_nursery() as nursery: - nursery.start_soon( - fetch_price, - 0, - "https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD", - lambda d: d["result"]["XXBTCZUSD"]["c"][0], - ) - nursery.start_soon( - fetch_price, - 1, - "https://www.bitstamp.net/api/v2/ticker/btcusd", - lambda d: d["last"], - ) - nursery.start_soon( - fetch_price, - 2, - "https://api.coincap.io/v2/rates/bitcoin", - lambda d: d["data"]["rateUsd"], - ) - - satoshi_prices = [x for x in satoshi_prices if x] - return sum(satoshi_prices) / len(satoshi_prices) diff --git a/lnbits/extensions/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py index 74dd6e35..31b85559 100644 --- a/lnbits/extensions/lnurlp/lnurl.py +++ b/lnbits/extensions/lnurlp/lnurl.py @@ -5,10 +5,10 @@ from quart import jsonify, url_for, request from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore from lnbits.core.services import create_invoice +from lnbits.utils.exchange_rates import get_fiat_rate_satoshis from . import lnurlp_ext from .crud import increment_pay_link -from .helpers import get_fiat_rate @lnurlp_ext.route("/api/v1/lnurl/", methods=["GET"]) @@ -17,7 +17,7 @@ async def api_lnurl_response(link_id): if not link: return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK - rate = await get_fiat_rate(link.currency) if link.currency else 1 + rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 resp = LnurlPayResponse( callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True), min_sendable=math.ceil(link.min * rate) * 1000, @@ -39,7 +39,7 @@ async def api_lnurl_callback(link_id): return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK min, max = link.min, link.max - rate = await get_fiat_rate(link.currency) if link.currency else 1 + rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 if link.currency: # allow some fluctuation (as the fiat price may have changed between the calls) min = rate * 995 * link.min diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py index f68bc5b3..a0fe2ffd 100644 --- a/lnbits/extensions/lnurlp/views_api.py +++ b/lnbits/extensions/lnurlp/views_api.py @@ -4,6 +4,7 @@ from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore from lnbits.core.crud import get_user from lnbits.decorators import api_check_wallet_key, api_validate_post_request +from lnbits.utils.exchange_rates import get_fiat_rate_satoshis from . import lnurlp_ext from .crud import ( @@ -13,7 +14,6 @@ from .crud import ( update_pay_link, delete_pay_link, ) -from .helpers import get_fiat_rate @lnurlp_ext.route("/api/v1/links", methods=["GET"]) @@ -109,7 +109,7 @@ async def api_link_delete(link_id): @lnurlp_ext.route("/api/v1/rate/", methods=["GET"]) async def api_check_fiat_rate(currency): try: - rate = await get_fiat_rate(currency) + rate = await get_fiat_rate_satoshis(currency) except AssertionError: rate = None diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py index 2930809d..6b56cf55 100644 --- a/lnbits/extensions/offlineshop/helpers.py +++ b/lnbits/extensions/offlineshop/helpers.py @@ -1,57 +1,9 @@ -import trio # type: ignore -import httpx import base64 import struct import hmac import time -async def get_fiat_rate(currency: str): - assert currency == "USD", "Only USD is supported as fiat currency." - return await get_usd_rate() - - -async def get_usd_rate(): - """ - Returns an average satoshi price from multiple sources. - """ - - satoshi_prices = [None, None, None] - - async def fetch_price(index, url, getter): - try: - async with httpx.AsyncClient() as client: - r = await client.get(url) - r.raise_for_status() - satoshi_price = int(100_000_000 / float(getter(r.json()))) - satoshi_prices[index] = satoshi_price - except Exception: - pass - - async with trio.open_nursery() as nursery: - nursery.start_soon( - fetch_price, - 0, - "https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD", - lambda d: d["result"]["XXBTCZUSD"]["c"][0], - ) - nursery.start_soon( - fetch_price, - 1, - "https://www.bitstamp.net/api/v2/ticker/btcusd", - lambda d: d["last"], - ) - nursery.start_soon( - fetch_price, - 2, - "https://api.coincap.io/v2/rates/bitcoin", - lambda d: d["data"]["rateUsd"], - ) - - satoshi_prices = [x for x in satoshi_prices if x] - return sum(satoshi_prices) / len(satoshi_prices) - - def hotp(key, counter, digits=6, digest="sha1"): key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8)) counter = struct.pack(">Q", counter) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index 7abf070e..d1e11c0c 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -3,10 +3,10 @@ from quart import jsonify, url_for, request from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore from lnbits.core.services import create_invoice +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from . import offlineshop_ext from .crud import get_shop, get_item -from .helpers import get_fiat_rate @offlineshop_ext.route("/lnurl/", methods=["GET"]) @@ -18,8 +18,7 @@ async def lnurl_response(item_id): if not item.enabled: return jsonify({"status": "ERROR", "reason": "Item disabled."}) - rate = await get_fiat_rate(item.unit) if item.unit != "sat" else 1 - price_msat = int(item.price * rate) * 1000 + price_msat = (await fiat_amount_as_satoshis(item.price, item.unit) if item.unit != "sat" else item.price) * 1000 resp = LnurlPayResponse( callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True), @@ -41,10 +40,10 @@ async def lnurl_callback(item_id): min = item.price * 1000 max = item.price * 1000 else: - rate = await get_fiat_rate(item.unit) + price = await fiat_amount_as_satoshis(item.price, item.unit) # allow some fluctuation (the fiat price may have changed between the calls) - min = int(rate * item.price) * 995 - max = int(rate * item.price) * 1010 + min = price * 995 + max = price * 1010 amount_received = int(request.args.get("amount")) if amount_received < min: diff --git a/lnbits/utils/__init__.py b/lnbits/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py new file mode 100644 index 00000000..506f6daf --- /dev/null +++ b/lnbits/utils/exchange_rates.py @@ -0,0 +1,262 @@ +import trio # type: ignore +import httpx +from typing import Callable, NamedTuple + +currencies = { + "AED": "United Arab Emirates Dirham", + "AFN": "Afghan Afghani", + "ALL": "Albanian Lek", + "AMD": "Armenian Dram", + "ANG": "Netherlands Antillean Gulden", + "AOA": "Angolan Kwanza", + "ARS": "Argentine Peso", + "AUD": "Australian Dollar", + "AWG": "Aruban Florin", + "AZN": "Azerbaijani Manat", + "BAM": "Bosnia and Herzegovina Convertible Mark", + "BBD": "Barbadian Dollar", + "BDT": "Bangladeshi Taka", + "BGN": "Bulgarian Lev", + "BHD": "Bahraini Dinar", + "BIF": "Burundian Franc", + "BMD": "Bermudian Dollar", + "BND": "Brunei Dollar", + "BOB": "Bolivian Boliviano", + "BRL": "Brazilian Real", + "BSD": "Bahamian Dollar", + "BTN": "Bhutanese Ngultrum", + "BWP": "Botswana Pula", + "BYN": "Belarusian Ruble", + "BYR": "Belarusian Ruble", + "BZD": "Belize Dollar", + "CAD": "Canadian Dollar", + "CDF": "Congolese Franc", + "CHF": "Swiss Franc", + "CLF": "Unidad de Fomento", + "CLP": "Chilean Peso", + "CNH": "Chinese Renminbi Yuan Offshore", + "CNY": "Chinese Renminbi Yuan", + "COP": "Colombian Peso", + "CRC": "Costa Rican Colón", + "CUC": "Cuban Convertible Peso", + "CVE": "Cape Verdean Escudo", + "CZK": "Czech Koruna", + "DJF": "Djiboutian Franc", + "DKK": "Danish Krone", + "DOP": "Dominican Peso", + "DZD": "Algerian Dinar", + "EGP": "Egyptian Pound", + "ERN": "Eritrean Nakfa", + "ETB": "Ethiopian Birr", + "EUR": "Euro", + "FJD": "Fijian Dollar", + "FKP": "Falkland Pound", + "GBP": "British Pound", + "GEL": "Georgian Lari", + "GGP": "Guernsey Pound", + "GHS": "Ghanaian Cedi", + "GIP": "Gibraltar Pound", + "GMD": "Gambian Dalasi", + "GNF": "Guinean Franc", + "GTQ": "Guatemalan Quetzal", + "GYD": "Guyanese Dollar", + "HKD": "Hong Kong Dollar", + "HNL": "Honduran Lempira", + "HRK": "Croatian Kuna", + "HTG": "Haitian Gourde", + "HUF": "Hungarian Forint", + "IDR": "Indonesian Rupiah", + "ILS": "Israeli New Sheqel", + "IMP": "Isle of Man Pound", + "INR": "Indian Rupee", + "IQD": "Iraqi Dinar", + "ISK": "Icelandic Króna", + "JEP": "Jersey Pound", + "JMD": "Jamaican Dollar", + "JOD": "Jordanian Dinar", + "JPY": "Japanese Yen", + "KES": "Kenyan Shilling", + "KGS": "Kyrgyzstani Som", + "KHR": "Cambodian Riel", + "KMF": "Comorian Franc", + "KRW": "South Korean Won", + "KWD": "Kuwaiti Dinar", + "KYD": "Cayman Islands Dollar", + "KZT": "Kazakhstani Tenge", + "LAK": "Lao Kip", + "LBP": "Lebanese Pound", + "LKR": "Sri Lankan Rupee", + "LRD": "Liberian Dollar", + "LSL": "Lesotho Loti", + "LYD": "Libyan Dinar", + "MAD": "Moroccan Dirham", + "MDL": "Moldovan Leu", + "MGA": "Malagasy Ariary", + "MKD": "Macedonian Denar", + "MMK": "Myanmar Kyat", + "MNT": "Mongolian Tögrög", + "MOP": "Macanese Pataca", + "MRO": "Mauritanian Ouguiya", + "MUR": "Mauritian Rupee", + "MVR": "Maldivian Rufiyaa", + "MWK": "Malawian Kwacha", + "MXN": "Mexican Peso", + "MYR": "Malaysian Ringgit", + "MZN": "Mozambican Metical", + "NAD": "Namibian Dollar", + "NGN": "Nigerian Naira", + "NIO": "Nicaraguan Córdoba", + "NOK": "Norwegian Krone", + "NPR": "Nepalese Rupee", + "NZD": "New Zealand Dollar", + "OMR": "Omani Rial", + "PAB": "Panamanian Balboa", + "PEN": "Peruvian Sol", + "PGK": "Papua New Guinean Kina", + "PHP": "Philippine Peso", + "PKR": "Pakistani Rupee", + "PLN": "Polish Złoty", + "PYG": "Paraguayan Guaraní", + "QAR": "Qatari Riyal", + "RON": "Romanian Leu", + "RSD": "Serbian Dinar", + "RUB": "Russian Ruble", + "RWF": "Rwandan Franc", + "SAR": "Saudi Riyal", + "SBD": "Solomon Islands Dollar", + "SCR": "Seychellois Rupee", + "SEK": "Swedish Krona", + "SGD": "Singapore Dollar", + "SHP": "Saint Helenian Pound", + "SLL": "Sierra Leonean Leone", + "SOS": "Somali Shilling", + "SRD": "Surinamese Dollar", + "SSP": "South Sudanese Pound", + "STD": "São Tomé and Príncipe Dobra", + "SVC": "Salvadoran Colón", + "SZL": "Swazi Lilangeni", + "THB": "Thai Baht", + "TJS": "Tajikistani Somoni", + "TMT": "Turkmenistani Manat", + "TND": "Tunisian Dinar", + "TOP": "Tongan Paʻanga", + "TRY": "Turkish Lira", + "TTD": "Trinidad and Tobago Dollar", + "TWD": "New Taiwan Dollar", + "TZS": "Tanzanian Shilling", + "UAH": "Ukrainian Hryvnia", + "UGX": "Ugandan Shilling", + "USD": "US Dollar", + "UYU": "Uruguayan Peso", + "UZS": "Uzbekistan Som", + "VEF": "Venezuelan Bolívar", + "VES": "Venezuelan Bolívar Soberano", + "VND": "Vietnamese Đồng", + "VUV": "Vanuatu Vatu", + "WST": "Samoan Tala", + "XAF": "Central African Cfa Franc", + "XAG": "Silver (Troy Ounce)", + "XAU": "Gold (Troy Ounce)", + "XCD": "East Caribbean Dollar", + "XDR": "Special Drawing Rights", + "XOF": "West African Cfa Franc", + "XPD": "Palladium", + "XPF": "Cfp Franc", + "XPT": "Platinum", + "YER": "Yemeni Rial", + "ZAR": "South African Rand", + "ZMW": "Zambian Kwacha", + "ZWL": "Zimbabwean Dollar", +} + + +class Provider(NamedTuple): + name: str + domain: str + api_url: str + getter: Callable + + +exchange_rate_providers = { + "bitfinex": Provider( + "Bitfinex", + "bitfinex.com", + "https://api.bitfinex.com/v1/pubticker/{from}{to}", + lambda data, replacements: data["last_price"], + ), + "bitstamp": Provider( + "Bitstamp", + "bitstamp.net", + "https://www.bitstamp.net/api/v2/ticker/{from}{to}/", + lambda data, replacements: data["last"], + ), + "coinbase": Provider( + "Coinbase", + "coinbase.com", + "https://api.coinbase.com/v2/exchange-rates?currency={FROM}", + lambda data, replacements: data["data"]["rates"][replacements["TO"]], + ), + "coinmate": Provider( + "CoinMate", + "coinmate.io", + "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}", + lambda data, replacements: data["data"]["last"], + ), + "kraken": Provider( + "Kraken", + "kraken.com", + "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}", + lambda data, replacements: data["result"]["XXBTZ" + replacements["TO"]]["c"][0], + ), +} + + +async def btc_price(currency: str) -> float: + replacements = {"FROM": "BTC", "from": "btc", "TO": currency.upper(), "to": currency.lower()} + rates = [] + send_channel, receive_channel = trio.open_memory_channel(0) + + async def controller(nursery): + failures = 0 + while True: + rate = await receive_channel.receive() + if rate: + rates.append(rate) + else: + failures += 1 + if len(rates) >= 2 or len(rates) == 1 and failures >= 2: + nursery.cancel_scope.cancel() + break + if failures == len(exchange_rate_providers): + nursery.cancel_scope.cancel() + break + + async def fetch_price(key: str, provider: Provider): + try: + url = provider.api_url.format(**replacements) + async with httpx.AsyncClient() as client: + r = await client.get(url, timeout=0.5) + r.raise_for_status() + data = r.json() + rate = float(provider.getter(data, replacements)) + await send_channel.send(rate) + except Exception: + await send_channel.send(None) + + async with trio.open_nursery() as nursery: + nursery.start_soon(controller, nursery) + for key, provider in exchange_rate_providers.items(): + nursery.start_soon(fetch_price, key, provider) + + if not rates: + return 9999999999 + + return sum([rate for rate in rates]) / len(rates) + + +async def get_fiat_rate_satoshis(currency: str) -> float: + return int(100_000_000 / (await btc_price(currency))) + + +async def fiat_amount_as_satoshis(amount: float, currency: str) -> int: + return int(amount * (await get_fiat_rate_satoshis(currency)))