[feat] custom exchange providers (#2797)

This commit is contained in:
Vlad Stan 2024-12-13 14:01:54 +02:00 committed by GitHub
parent 200b9b127c
commit 524a4c9213
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 665 additions and 130 deletions

View file

@ -26,6 +26,7 @@ from lnbits.core.helpers import migrate_extension_database
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.tasks import ( # watchdog_task
audit_queue,
collect_exchange_rates_data,
killswitch_task,
purge_audit_data,
wait_for_audit_data,
@ -455,6 +456,7 @@ def register_async_tasks(app: FastAPI):
# create_permanent_task(watchdog_task)
create_permanent_task(killswitch_task)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
# server logs for websocket
if settings.lnbits_admin_ui:

View file

@ -4,6 +4,7 @@ from typing import Any, Optional
from loguru import logger
from lnbits.core.db import db
from lnbits.db import dict_to_model
from lnbits.settings import (
AdminSettings,
EditableSettings,
@ -18,7 +19,8 @@ async def get_super_settings() -> Optional[SuperSettings]:
if data:
super_user = await get_settings_field("super_user")
super_user_id = super_user.value if super_user else None
return SuperSettings(**{"super_user": super_user_id, **data})
settings_dict = {"super_user": super_user_id, **data}
return dict_to_model(settings_dict, SuperSettings)
return None

View file

@ -3,6 +3,7 @@ from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.db import dict_to_model
from lnbits.settings import (
EditableSettings,
readonly_variables,
@ -37,14 +38,16 @@ async def check_webpush_settings():
def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items():
editable_settings = dict_to_model(sets_dict, EditableSettings)
for key in sets_dict.keys():
if key in readonly_variables:
continue
if key not in settings.dict().keys():
continue
try:
value = getattr(editable_settings, key)
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}")
logger.warning(f"Failed overriding setting: {key}.")
if "super_user" in sets_dict:
settings.super_user = sets_dict["super_user"]

View file

@ -19,6 +19,7 @@ from lnbits.core.services import (
)
from lnbits.settings import get_funding_source, settings
from lnbits.tasks import send_push_notification
from lnbits.utils.exchange_rates import btc_rates
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
audit_queue: asyncio.Queue = asyncio.Queue()
@ -188,3 +189,27 @@ async def purge_audit_data():
# clean every hour
await asyncio.sleep(60 * 60)
async def collect_exchange_rates_data():
"""
Collect exchange rates data. Used for monitoring only.
"""
while settings.lnbits_running:
currency = settings.lnbits_default_accounting_currency or "USD"
max_history_size = settings.lnbits_exchange_history_size
sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds
if sleep_time > 0:
try:
rates = await btc_rates(currency)
if rates:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
else:
sleep_time = 60
await asyncio.sleep(sleep_time)

View file

@ -0,0 +1,195 @@
<q-tab-panel name="exchange_providers">
<h6 class="q-my-none q-mb-sm">Exchange Providers</h6>
<div class="row">
<div class="col-md-8 col-sm-12">
<div class="q-pa-sm">
<canvas
style="
width: 100% !important;
height: auto !important;
min-height: 350px;
max-height: 50vh;
"
ref="exchangeRatesChart"
></canvas>
</div>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
type="number"
label="Refresh Interval (seconds)"
hint="How often should the exchange rates be fetched. Set to zero to disable."
>
</q-input>
<q-input
filled
v-model="formData.lnbits_exchange_history_size"
type="number"
label="History Size"
hint="How many data points should be kept in memory."
>
</q-input>
<ul>
<li>
<code>Refresh Interval</code> and <code> History Size </code>are for
historical purposes only.
</li>
<li>These two settings do not affect the live price computation.</li>
<li>
Chart currency:
<strong
><span
v-text="formData.lnbits_default_accounting_currency || 'USD'"
></span
></strong>
</li>
</ul>
</div>
</div>
<div class="row q-mt-md">
<div class="col-6">
<q-btn
@click="addExchangeProvider()"
label="Add Exchange Provider"
color="primary"
class="q-mb-md"
>
</q-btn>
</div>
<div class="col-6">
<q-btn
@click="getDefaultSetting('lnbits_exchange_rate_providers')"
flat
:label="$t('reset_defaults')"
color="primary"
class="float-right"
>
</q-btn>
</div>
</div>
<q-table
row-key="name"
:rows="formData.lnbits_exchange_rate_providers"
:columns="exchangesTable.columns"
v-model:pagination="exchangesTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<q-btn
@click="removeExchangeProvider(props.row)"
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
>
</q-btn>
</q-td>
<q-td>
<q-input
dense
filled
v-model="props.row.name"
@update:model-value="touchSettings()"
type="text"
>
</q-input>
</q-td>
<q-td full-width>
<q-input
dense
filled
v-model="props.row.api_url"
@update:model-value="touchSettings()"
type="text"
>
</q-input
></q-td>
<q-td>
<q-input
dense
filled
v-model="props.row.path"
@update:model-value="touchSettings()"
type="text"
>
</q-input>
</q-td>
<q-td>
<q-select
filled
dense
v-model="props.row.exclude_to"
@update:model-value="touchSettings()"
multiple
:options="{{ currencies | safe }}"
></q-select>
</q-td>
<q-td>
<q-btn
@click="showTickerConversionDialog(props.row)"
round
icon="add"
size="sm"
color="gray"
class="q-ml-xs"
>
</q-btn>
<q-chip
v-for="ticker, index in props.row.ticker_conversion"
:key="ticker"
removable
dense
filled
@remove="removeExchangeTickerConversion(props.row, ticker)"
color="primary"
text-color="white"
:label="ticker"
class="ellipsis"
>
</q-chip>
</q-td>
</q-tr>
</template>
</q-table>
<ul>
<li>
<code>API URL</code> and <code>JSON Path</code> fields can use the
<code>{to}</code> and <code>{TO}</code> placeholders for the code of the
currency
</li>
<li>
<code>{TO}</code> is the uppercase code and <code>{to}</code> is the
lowercase code
</li>
</ul>
<q-separator class="q-ma-md"></q-separator>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.lnbits_exchange_rate_cache_seconds"
type="number"
label="Exchange rate cache (seconds)"
hint="For how many seconds should the exchange rate be cached."
>
</q-input>
</div>
<div class="col-md-8 col-sm-12"></div>
</div>
</q-tab-panel>

View file

@ -63,7 +63,12 @@
<q-card>
<q-splitter>
<template v-slot:before>
<q-tabs v-model="tab" vertical active-color="primary">
<q-tabs
@update:model-value="showExchangeProvidersTab"
v-model="tab"
vertical
active-color="primary"
>
<q-tab
name="funding"
icon="account_balance_wallet"
@ -80,23 +85,6 @@
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('security')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
icon="group"
:label="$q.screen.gt.sm ? $t('users') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('users')"></span></q-tooltip
></q-tab>
<q-tab
name="extensions"
icon="extension"
:label="$q.screen.gt.sm ? $t('extensions') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('extensions')"></span></q-tooltip
></q-tab>
<q-tab
name="server"
icon="price_change"
@ -105,6 +93,31 @@
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('payments')"></span></q-tooltip
></q-tab>
<q-tab
name="exchange_providers"
icon="show_chart"
:label="$q.screen.gt.sm ? $t('exchanges') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
icon="group"
:label="$q.screen.gt.sm ? $t('users') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('users')"></span></q-tooltip
></q-tab>
<q-tab
name="extensions"
icon="extension"
:label="$q.screen.gt.sm ? $t('extensions') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('extensions')"></span></q-tooltip
></q-tab>
<q-tab
name="notifications"
icon="notifications"
@ -145,7 +158,8 @@
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
{% include "admin/_tab_extensions.html" %} {% include
{% include "admin/_tab_exchange_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}
@ -157,6 +171,47 @@
</div>
</div>
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>Create Currecny Ticker Converter</strong>
<div class="row">
<div class="col-12 q-mb-md">
<q-select
filled
dense
v-model="exchangeData.convertFromTicker"
label="From Currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
<div class="col-12">
<q-input
v-model="exchangeData.convertToTicker"
dense
filled
label="New Ticker"
hint="This ticker will be used for the exchange API calls."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
@click="addExchangeTickerConversion()"
label="Add Ticker Conversion"
color="primary"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
{% endblock %}

View file

@ -17,7 +17,7 @@ from lnbits.core.services import (
from lnbits.core.tasks import api_invoice_listeners
from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, UpdateSettings, settings
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
from .. import core_app_extra
@ -70,6 +70,16 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
return {"status": "Success"}
@admin_router.get(
"/api/v1/settings/default",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def api_reset_settings(field_name: str):
default_settings = Settings()
return {"default_value": getattr(default_settings, field_name)}
@admin_router.delete(
"/api/v1/settings",
status_code=HTTPStatus.OK,

View file

@ -225,6 +225,14 @@ async def api_perform_lnurlauth(
return ""
@api_router.get(
"/api/v1/rate/history",
dependencies=[Depends(require_invoice_key)],
)
async def api_exchange_rate_history() -> list[dict]:
return settings.lnbits_exchange_rate_history
@api_router.get("/api/v1/rate/{currency}")
async def api_check_fiat_rate(currency: str) -> dict[str, float]:
rate = await get_fiat_rate_satoshis(currency)

View file

@ -5,6 +5,7 @@ import importlib.metadata
import inspect
import json
import re
from datetime import datetime, timezone
from enum import Enum
from hashlib import sha256
from os import path
@ -118,6 +119,26 @@ class RedirectPath(BaseModel):
return False
class ExchangeRateProvider(BaseModel):
name: str
api_url: str
path: str
exclude_to: list[str] = []
ticker_conversion: list[str] = []
def convert_ticker(self, currency: str) -> str:
if not self.ticker_conversion:
return currency
try:
for t in self.ticker_conversion:
_from, _to = t.split(":")
if _from == currency:
return _to
except Exception as err:
logger.warning(err)
return currency
class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=[])
@ -193,6 +214,20 @@ class InstalledExtensionsSettings(LNbitsSettings):
]
class ExchangeHistorySettings(LNbitsSettings):
lnbits_exchange_rate_history: list[dict] = Field(default=[])
def append_exchange_rate_datapoint(self, rates: dict, max_size: int):
data = {
"timestamp": int(datetime.now(timezone.utc).timestamp()),
"rates": rates,
}
self.lnbits_exchange_rate_history.append(data)
if len(self.lnbits_exchange_rate_history) > max_size:
self.lnbits_exchange_rate_history.pop(0)
class ThemesSettings(LNbitsSettings):
lnbits_site_title: str = Field(default="LNbits")
lnbits_site_tagline: str = Field(default="free and open-source lightning wallet")
@ -250,6 +285,80 @@ class FeeSettings(LNbitsSettings):
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=30)
lnbits_exchange_history_size: int = Field(default=60)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300)
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[
ExchangeRateProvider(
name="Binance",
api_url="https://api.binance.com/api/v3/ticker/price?symbol=BTC{TO}",
path="$.price",
exclude_to=["czk"],
ticker_conversion=["USD:USDT"],
),
ExchangeRateProvider(
name="Blockchain",
api_url="https://blockchain.info/frombtc?currency={TO}&value=100000000",
path="",
exclude_to=[],
ticker_conversion=[],
),
ExchangeRateProvider(
name="Exir",
api_url="https://api.exir.io/v1/ticker?symbol=btc-{to}",
path="$.last",
exclude_to=["czk", "eur"],
ticker_conversion=["USD:USDT"],
),
ExchangeRateProvider(
name="Bitfinex",
api_url="https://api.bitfinex.com/v1/pubticker/btc{to}",
path="$.last_price",
exclude_to=["czk"],
ticker_conversion=[],
),
ExchangeRateProvider(
name="Bitstamp",
api_url="https://www.bitstamp.net/api/v2/ticker/btc{to}/",
path="$.last",
exclude_to=["czk"],
ticker_conversion=[],
),
ExchangeRateProvider(
name="Coinbase",
api_url="https://api.coinbase.com/v2/exchange-rates?currency=BTC",
path="$.data.rates.{TO}",
exclude_to=[],
ticker_conversion=[],
),
ExchangeRateProvider(
name="CoinMate",
api_url="https://coinmate.io/api/ticker?currencyPair=BTC_{TO}",
path="$.data.last",
exclude_to=[],
ticker_conversion=["USD:USDT"],
),
ExchangeRateProvider(
name="Kraken",
api_url="https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
path="$.result.XXBTZ{TO}.c[0]",
exclude_to=["czk"],
ticker_conversion=[],
),
ExchangeRateProvider(
name="yadio",
api_url="https://api.yadio.io/exrates/BTC",
path="$.BTC.{TO}",
exclude_to=[],
ticker_conversion=[],
),
]
)
class SecuritySettings(LNbitsSettings):
lnbits_rate_limit_no: str = Field(default="200")
lnbits_rate_limit_unit: str = Field(default="minute")
@ -594,6 +703,7 @@ class EditableSettings(
ThemesSettings,
OpsSettings,
FeeSettings,
ExchangeProvidersSettings,
SecuritySettings,
FundingSourcesSettings,
LightningSettings,
@ -698,7 +808,7 @@ class SuperUserSettings(LNbitsSettings):
)
class TransientSettings(InstalledExtensionsSettings):
class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
# Transient Settings:
# - are initialized, updated and used at runtime
# - are not read from a file or from the `settings` table

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -108,6 +108,7 @@ window.localisation.en = {
'You *must* save your login credentials to be able to access your wallet again. If you lose them, you will lose access to your wallet and funds.\n\nFind your login credentials on your account settings page.\n\nThis service is in BETA. LNbits holds no responsibility for loss of access to funds.',
no_transactions: 'No transactions made yet',
manage: 'Manage',
exchanges: 'Exchanges',
extensions: 'Extensions',
no_extensions: "You don't have any extensions installed :(",
created: 'Created',

View file

@ -61,7 +61,61 @@ window.app = Vue.createApp({
'orange'
],
tab: 'funding',
needsRestart: false
needsRestart: false,
exchangesTable: {
columns: [
{
name: 'name',
align: 'left',
label: 'Exchange Name',
field: 'name',
sortable: true
},
{
name: 'api_url',
align: 'left',
label: 'URL',
field: 'api_url',
sortable: false
},
{
name: 'path',
align: 'left',
label: 'JSON Path',
field: 'path',
sortable: false
},
{
name: 'exclude_to',
align: 'left',
label: 'Exclude Currencies',
field: 'exclude_to',
sortable: false
},
{
name: 'ticker_conversion',
align: 'left',
label: 'Ticker Conversion',
field: 'ticker_conversion',
sortable: false
}
],
pagination: {
sortBy: 'name',
rowsPerPage: 100,
page: 1,
rowsNumber: 100
},
search: null,
hideEmpty: true
},
exchangeData: {
selectedProvider: null,
showTickerConversion: false,
convertFromTicker: null,
convertToTicker: null
}
}
},
created() {
@ -247,6 +301,57 @@ window.app = Vue.createApp({
this.formData.nostr_absolute_request_urls =
this.formData.nostr_absolute_request_urls.filter(b => b !== url)
},
addExchangeProvider() {
this.formData.lnbits_exchange_rate_providers = [
{
name: '',
api_url: '',
path: '',
exclude_to: []
},
...this.formData.lnbits_exchange_rate_providers
]
},
removeExchangeProvider(provider) {
this.formData.lnbits_exchange_rate_providers =
this.formData.lnbits_exchange_rate_providers.filter(p => p !== provider)
},
removeExchangeTickerConversion(provider, ticker) {
provider.ticker_conversion = provider.ticker_conversion.filter(
t => t !== ticker
)
this.touchSettings()
},
addExchangeTickerConversion() {
if (!this.exchangeData.selectedProvider) {
return
}
this.exchangeData.selectedProvider.ticker_conversion.push(
`${this.exchangeData.convertFromTicker}:${this.exchangeData.convertToTicker}`
)
this.touchSettings()
this.exchangeData.showTickerConversion = false
},
showTickerConversionDialog(provider) {
this.exchangeData.convertFromTicker = null
this.exchangeData.convertToTicker = null
this.exchangeData.selectedProvider = provider
this.exchangeData.showTickerConversion = true
},
getDefaultSetting(fieldName) {
LNbits.api
.request(
'GET',
`/admin/api/v1/settings/default?field_name=${fieldName}`
)
.then(response => {
this.formData[fieldName] = response.data.default_value
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
restartServer() {
LNbits.api
.request('GET', '/admin/api/v1/restart/')
@ -286,6 +391,16 @@ window.app = Vue.createApp({
})
.catch(LNbits.utils.notifyApiError)
},
getExchangeRateHistory() {
LNbits.api
.request('GET', '/api/v1/rate/history', this.g.user.wallets[0].inkey)
.then(response => {
this.initExchangeChart(response.data)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getSettings() {
LNbits.api
.request(
@ -304,7 +419,8 @@ window.app = Vue.createApp({
updateSettings() {
const data = _.omit(this.formData, [
'is_super_user',
'lnbits_allowed_funding_sources'
'lnbits_allowed_funding_sources',
'touch'
])
LNbits.api
.request(
@ -350,6 +466,47 @@ window.app = Vue.createApp({
},
downloadBackup() {
window.open('/admin/api/v1/backup', '_blank')
},
showExchangeProvidersTab(tabName) {
if (tabName === 'exchange_providers') {
this.getExchangeRateHistory()
}
},
touchSettings() {
this.formData.touch = null
},
initExchangeChart(data) {
const xValues = data.map(d =>
Quasar.date.formatDate(new Date(d.timestamp * 1000), 'HH:mm')
)
const exchanges = [
...this.formData.lnbits_exchange_rate_providers,
{name: 'LNbits'}
]
const datasets = exchanges.map(exchange => ({
label: exchange.name,
data: data.map(d => d.rates[exchange.name]),
pointStyle: true,
borderWidth: exchange.name === 'LNbits' ? 4 : 1,
tension: 0.4
}))
this.exchangeRatesChart = new Chart(
this.$refs.exchangeRatesChart.getContext('2d'),
{
type: 'line',
options: {
plugins: {
legend: {
display: false
}
}
},
data: {
labels: xValues,
datasets
}
}
)
}
}
})

View file

@ -1,10 +1,10 @@
import asyncio
from typing import Callable, NamedTuple
from typing import Optional
import httpx
import jsonpath_ng.ext as jpx
from loguru import logger
from lnbits.settings import settings
from lnbits.settings import ExchangeRateProvider, settings
from lnbits.utils.cache import cache
currencies = {
@ -186,130 +186,70 @@ def allowed_currencies():
return list(currencies.keys())
class Provider(NamedTuple):
name: str
domain: str
api_url: str
getter: Callable
exclude_to: list = []
async def btc_rates(currency: str) -> list[tuple[str, float]]:
def replacements(ticker: str):
return {
"FROM": "BTC",
"from": "btc",
"TO": ticker.upper(),
"to": ticker.lower(),
}
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"],
["czk"],
),
"blockchain": Provider(
"Blockchain",
"blockchain.com",
"https://blockchain.info/tobtc?currency={TO}&value=1000000",
lambda data, replacements: 1000000 / data,
),
"exir": Provider(
"Exir",
"exir.io",
"https://api.exir.io/v1/ticker?symbol={from}-{to}",
lambda data, replacements: data["last"],
["czk", "eur"],
),
"bitfinex": Provider(
"Bitfinex",
"bitfinex.com",
"https://api.bitfinex.com/v1/pubticker/{from}{to}",
lambda data, replacements: data["last_price"],
["czk"],
),
"bitstamp": Provider(
"Bitstamp",
"bitstamp.net",
"https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
lambda data, replacements: data["last"],
["czk"],
),
"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],
["czk"],
),
"bitpay": Provider(
"BitPay",
"bitpay.com",
"https://bitpay.com/rates",
lambda data, replacements: next(
i["rate"] for i in data["data"] if i["code"] == replacements["TO"]
),
),
"yadio": Provider(
"yadio",
"yadio.io",
"https://api.yadio.io/exrates/{FROM}",
lambda data, replacements: data[replacements["FROM"]][replacements["TO"]],
),
}
async def btc_price(currency: str) -> float:
replacements = {
"FROM": "BTC",
"from": "btc",
"TO": currency.upper(),
"to": currency.lower(),
}
async def fetch_price(provider: Provider):
async def fetch_price(
provider: ExchangeRateProvider,
) -> Optional[tuple[str, float]]:
if currency.lower() in provider.exclude_to:
raise Exception(f"Provider {provider.name} does not support {currency}.")
url = provider.api_url.format(**replacements)
ticker = provider.convert_ticker(currency)
url = provider.api_url.format(**replacements(ticker))
json_path = provider.path.format(**replacements(ticker))
try:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
r = await client.get(url, timeout=0.5)
r.raise_for_status()
if not provider.path:
return provider.name, float(r.text.replace(",", ""))
data = r.json()
return float(provider.getter(data, replacements))
price_query = jpx.parse(json_path)
result = price_query.find(data)
return provider.name, float(result[0].value)
except Exception as e:
logger.warning(
f"Failed to fetch Bitcoin price "
f"for {currency} from {provider.name}: {e}"
)
raise
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, BaseException)]
return None
# OK to be in squence: fetch_price times out after 0.5 seconds
results = [
await fetch_price(provider)
for provider in settings.lnbits_exchange_rate_providers
]
return [r for r in results if r is not None]
async def btc_price(currency: str) -> float:
rates = await btc_rates(currency)
if not rates:
return 9999999999
elif len(rates) == 1:
logger.warning("Could only fetch one Bitcoin price.")
return sum(rates) / len(rates)
rates_values = [r[1] for r in rates]
return sum(rates_values) / len(rates_values)
async def get_fiat_rate_satoshis(currency: str) -> float:
price = await cache.save_result(
lambda: btc_price(currency), f"btc-price-{currency}"
lambda: btc_price(currency),
f"btc-price-{currency}",
settings.lnbits_exchange_rate_cache_seconds,
)
return float(100_000_000 / price)

28
poetry.lock generated
View file

@ -1410,6 +1410,21 @@ files = [
{file = "json5-0.9.25.tar.gz", hash = "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"},
]
[[package]]
name = "jsonpath-ng"
version = "1.7.0"
description = "A final implementation of JSONPath for Python that aims to be standard compliant, including arithmetic and binary comparison operators and providing clear AST for metaprogramming."
optional = false
python-versions = "*"
files = [
{file = "jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c"},
{file = "jsonpath_ng-1.7.0-py2-none-any.whl", hash = "sha256:898c93fc173f0c336784a3fa63d7434297544b7198124a68f9a3ef9597b0ae6e"},
{file = "jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6"},
]
[package.dependencies]
ply = "*"
[[package]]
name = "jsonschema"
version = "4.20.0"
@ -1891,6 +1906,17 @@ files = [
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "ply"
version = "3.11"
description = "Python Lex & Yacc"
optional = false
python-versions = "*"
files = [
{file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"},
{file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"},
]
[[package]]
name = "pre-commit"
version = "3.8.0"
@ -3187,4 +3213,4 @@ liquid = ["wallycore"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12 | ^3.11 | ^3.10 | ^3.9"
content-hash = "7cba29a0ac6386ae77c238cb8fccb2c1801cb7d6a3c6a079fdb9625a953145d4"
content-hash = "e8077cd36e9647b0715d8aceeda4505eef0fe3d8eb0eb599025ea1584855c134"

View file

@ -60,6 +60,7 @@ wallycore = {version = "1.3.0", optional = true}
# needed for breez funding source
breez-sdk = {version = "0.5.2", optional = true}
jsonpath-ng = "^1.7.0"
[tool.poetry.extras]
breez = ["breez-sdk"]
liquid = ["wallycore"]
@ -140,6 +141,7 @@ module = [
"pywebpush.*",
"fastapi_sso.sso.*",
"json5.*",
"jsonpath_ng.*",
]
ignore_missing_imports = "True"