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

View file

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

View file

@ -3,6 +3,7 @@ from loguru import logger
from py_vapid import Vapid from py_vapid import Vapid
from py_vapid.utils import b64urlencode from py_vapid.utils import b64urlencode
from lnbits.db import dict_to_model
from lnbits.settings import ( from lnbits.settings import (
EditableSettings, EditableSettings,
readonly_variables, readonly_variables,
@ -37,14 +38,16 @@ async def check_webpush_settings():
def update_cached_settings(sets_dict: dict): 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: if key in readonly_variables:
continue continue
if key not in settings.dict().keys(): if key not in settings.dict().keys():
continue continue
try: try:
value = getattr(editable_settings, key)
setattr(settings, key, value) setattr(settings, key, value)
except Exception: except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}") logger.warning(f"Failed overriding setting: {key}.")
if "super_user" in sets_dict: if "super_user" in sets_dict:
settings.super_user = sets_dict["super_user"] 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.settings import get_funding_source, settings
from lnbits.tasks import send_push_notification from lnbits.tasks import send_push_notification
from lnbits.utils.exchange_rates import btc_rates
api_invoice_listeners: Dict[str, asyncio.Queue] = {} api_invoice_listeners: Dict[str, asyncio.Queue] = {}
audit_queue: asyncio.Queue = asyncio.Queue() audit_queue: asyncio.Queue = asyncio.Queue()
@ -188,3 +189,27 @@ async def purge_audit_data():
# clean every hour # clean every hour
await asyncio.sleep(60 * 60) 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-card>
<q-splitter> <q-splitter>
<template v-slot:before> <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 <q-tab
name="funding" name="funding"
icon="account_balance_wallet" icon="account_balance_wallet"
@ -80,23 +85,6 @@
><q-tooltip v-if="!$q.screen.gt.sm" ><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('security')"></span></q-tooltip ><span v-text="$t('security')"></span></q-tooltip
></q-tab> ></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 <q-tab
name="server" name="server"
icon="price_change" icon="price_change"
@ -105,6 +93,31 @@
><q-tooltip v-if="!$q.screen.gt.sm" ><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('payments')"></span></q-tooltip ><span v-text="$t('payments')"></span></q-tooltip
></q-tab> ></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 <q-tab
name="notifications" name="notifications"
icon="notifications" icon="notifications"
@ -145,7 +158,8 @@
> >
{% include "admin/_tab_funding.html" %} {% include {% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} "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_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html" "admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%} %}{% include "admin/_tab_audit.html"%}
@ -157,6 +171,47 @@
</div> </div>
</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) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script> <script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -17,7 +17,7 @@ from lnbits.core.services import (
from lnbits.core.tasks import api_invoice_listeners from lnbits.core.tasks import api_invoice_listeners
from lnbits.decorators import check_admin, check_super_user from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart 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 lnbits.tasks import invoice_listeners
from .. import core_app_extra from .. import core_app_extra
@ -70,6 +70,16 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
return {"status": "Success"} 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( @admin_router.delete(
"/api/v1/settings", "/api/v1/settings",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,

View file

@ -225,6 +225,14 @@ async def api_perform_lnurlauth(
return "" 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}") @api_router.get("/api/v1/rate/{currency}")
async def api_check_fiat_rate(currency: str) -> dict[str, float]: async def api_check_fiat_rate(currency: str) -> dict[str, float]:
rate = await get_fiat_rate_satoshis(currency) rate = await get_fiat_rate_satoshis(currency)

View file

@ -5,6 +5,7 @@ import importlib.metadata
import inspect import inspect
import json import json
import re import re
from datetime import datetime, timezone
from enum import Enum from enum import Enum
from hashlib import sha256 from hashlib import sha256
from os import path from os import path
@ -118,6 +119,26 @@ class RedirectPath(BaseModel):
return False 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): class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated # installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=[]) 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): class ThemesSettings(LNbitsSettings):
lnbits_site_title: str = Field(default="LNbits") lnbits_site_title: str = Field(default="LNbits")
lnbits_site_tagline: str = Field(default="free and open-source lightning wallet") 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)) 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): class SecuritySettings(LNbitsSettings):
lnbits_rate_limit_no: str = Field(default="200") lnbits_rate_limit_no: str = Field(default="200")
lnbits_rate_limit_unit: str = Field(default="minute") lnbits_rate_limit_unit: str = Field(default="minute")
@ -594,6 +703,7 @@ class EditableSettings(
ThemesSettings, ThemesSettings,
OpsSettings, OpsSettings,
FeeSettings, FeeSettings,
ExchangeProvidersSettings,
SecuritySettings, SecuritySettings,
FundingSourcesSettings, FundingSourcesSettings,
LightningSettings, LightningSettings,
@ -698,7 +808,7 @@ class SuperUserSettings(LNbitsSettings):
) )
class TransientSettings(InstalledExtensionsSettings): class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
# Transient Settings: # Transient Settings:
# - are initialized, updated and used at runtime # - are initialized, updated and used at runtime
# - are not read from a file or from the `settings` table # - 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.', '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', no_transactions: 'No transactions made yet',
manage: 'Manage', manage: 'Manage',
exchanges: 'Exchanges',
extensions: 'Extensions', extensions: 'Extensions',
no_extensions: "You don't have any extensions installed :(", no_extensions: "You don't have any extensions installed :(",
created: 'Created', created: 'Created',

View file

@ -61,7 +61,61 @@ window.app = Vue.createApp({
'orange' 'orange'
], ],
tab: 'funding', 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() { created() {
@ -247,6 +301,57 @@ window.app = Vue.createApp({
this.formData.nostr_absolute_request_urls = this.formData.nostr_absolute_request_urls =
this.formData.nostr_absolute_request_urls.filter(b => b !== url) 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() { restartServer() {
LNbits.api LNbits.api
.request('GET', '/admin/api/v1/restart/') .request('GET', '/admin/api/v1/restart/')
@ -286,6 +391,16 @@ window.app = Vue.createApp({
}) })
.catch(LNbits.utils.notifyApiError) .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() { getSettings() {
LNbits.api LNbits.api
.request( .request(
@ -304,7 +419,8 @@ window.app = Vue.createApp({
updateSettings() { updateSettings() {
const data = _.omit(this.formData, [ const data = _.omit(this.formData, [
'is_super_user', 'is_super_user',
'lnbits_allowed_funding_sources' 'lnbits_allowed_funding_sources',
'touch'
]) ])
LNbits.api LNbits.api
.request( .request(
@ -350,6 +466,47 @@ window.app = Vue.createApp({
}, },
downloadBackup() { downloadBackup() {
window.open('/admin/api/v1/backup', '_blank') 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 Optional
from typing import Callable, NamedTuple
import httpx import httpx
import jsonpath_ng.ext as jpx
from loguru import logger from loguru import logger
from lnbits.settings import settings from lnbits.settings import ExchangeRateProvider, settings
from lnbits.utils.cache import cache from lnbits.utils.cache import cache
currencies = { currencies = {
@ -186,130 +186,70 @@ def allowed_currencies():
return list(currencies.keys()) return list(currencies.keys())
class Provider(NamedTuple): async def btc_rates(currency: str) -> list[tuple[str, float]]:
name: str def replacements(ticker: str):
domain: str return {
api_url: str "FROM": "BTC",
getter: Callable "from": "btc",
exclude_to: list = [] "TO": ticker.upper(),
"to": ticker.lower(),
}
async def fetch_price(
exchange_rate_providers = { provider: ExchangeRateProvider,
# https://binance-docs.github.io/apidocs/spot/en/#symbol-price-ticker ) -> Optional[tuple[str, float]]:
"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):
if currency.lower() in provider.exclude_to: if currency.lower() in provider.exclude_to:
raise Exception(f"Provider {provider.name} does not support {currency}.") 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: try:
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
r = await client.get(url, timeout=0.5) r = await client.get(url, timeout=0.5)
r.raise_for_status() r.raise_for_status()
if not provider.path:
return provider.name, float(r.text.replace(",", ""))
data = r.json() 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: except Exception as e:
logger.warning( logger.warning(
f"Failed to fetch Bitcoin price " f"Failed to fetch Bitcoin price "
f"for {currency} from {provider.name}: {e}" f"for {currency} from {provider.name}: {e}"
) )
raise
results = await asyncio.gather( return None
*[fetch_price(provider) for provider in exchange_rate_providers.values()],
return_exceptions=True,
)
rates = [r for r in results if not isinstance(r, BaseException)]
# 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: 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(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: async def get_fiat_rate_satoshis(currency: str) -> float:
price = await cache.save_result( 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) 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"}, {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]] [[package]]
name = "jsonschema" name = "jsonschema"
version = "4.20.0" version = "4.20.0"
@ -1891,6 +1906,17 @@ files = [
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"] 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]] [[package]]
name = "pre-commit" name = "pre-commit"
version = "3.8.0" version = "3.8.0"
@ -3187,4 +3213,4 @@ liquid = ["wallycore"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12 | ^3.11 | ^3.10 | ^3.9" 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 # needed for breez funding source
breez-sdk = {version = "0.5.2", optional = true} breez-sdk = {version = "0.5.2", optional = true}
jsonpath-ng = "^1.7.0"
[tool.poetry.extras] [tool.poetry.extras]
breez = ["breez-sdk"] breez = ["breez-sdk"]
liquid = ["wallycore"] liquid = ["wallycore"]
@ -140,6 +141,7 @@ module = [
"pywebpush.*", "pywebpush.*",
"fastapi_sso.sso.*", "fastapi_sso.sso.*",
"json5.*", "json5.*",
"jsonpath_ng.*",
] ]
ignore_missing_imports = "True" ignore_missing_imports = "True"