Compare commits

...
6 Commits
28 changed files with 1534 additions and 217 deletions
+1 -14
View File
@@ -7,10 +7,6 @@ on:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch:
inputs:
@@ -18,10 +14,6 @@ on:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs:
build-linux-package:
@@ -113,11 +105,6 @@ jobs:
shell: bash
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ inputs.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
-11
View File
@@ -12,8 +12,6 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
@@ -22,14 +20,6 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
@@ -74,4 +64,3 @@ jobs:
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
-11
View File
@@ -13,8 +13,6 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -23,14 +21,6 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
@@ -85,4 +75,3 @@ jobs:
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+1
View File
@@ -290,6 +290,7 @@ async def create_payment(
webhook=data.webhook,
fee=-abs(data.fee),
tag=extra.get("tag", None),
extension=data.extension,
extra=extra,
labels=data.labels or [],
external_id=data.external_id,
+15 -10
View File
@@ -8,11 +8,18 @@ from lnbits.db import dict_to_model
from lnbits.settings import (
AdminSettings,
EditableSettings,
FundingSourcesSettings,
SettingsField,
SuperSettings,
settings,
)
RESET_PRESERVED_SETTINGS = (
"lnbits_webpush_pubkey",
"lnbits_webpush_privkey",
*FundingSourcesSettings.__fields__,
)
async def get_super_settings() -> SuperSettings | None:
data = await get_settings_by_tag("core")
@@ -69,16 +76,14 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
async def reset_core_settings() -> None:
await db.execute(
"""
DELETE FROM system_settings WHERE tag = 'core'
AND id NOT IN (
'super_user',
'lnbits_webpush_pubkey',
'lnbits_webpush_privkey'
)
""",
)
core_settings = await get_settings_by_tag("core") or {}
super_user = await get_settings_field("super_user")
await delete_admin_settings()
if super_user:
await set_settings_field("super_user", super_user.value)
for field in RESET_PRESERVED_SETTINGS:
if field in core_settings:
await set_settings_field(field, core_settings[field])
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
+2
View File
@@ -54,6 +54,7 @@ class CreatePayment(BaseModel):
amount_msat: int
memo: str
extra: dict | None = {}
extension: str | None = None
preimage: str | None = None
expiry: datetime | None = None
webhook: str | None = None
@@ -259,6 +260,7 @@ class CreateInvoice(BaseModel):
)
expiry: int | None = None
extra: dict | None = None
extension: str | None = None
webhook: str | None = None
bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None
+3
View File
@@ -214,6 +214,7 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
extension=data.extension,
webhook=data.webhook,
internal=data.internal,
payment_hash=data.payment_hash,
@@ -259,6 +260,7 @@ async def create_invoice(
webhook: str | None = None,
internal: bool | None = False,
payment_hash: str | None = None,
extension: str | None = None,
labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None,
@@ -342,6 +344,7 @@ async def create_invoice(
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
extension=extension,
webhook=webhook,
fee=invoice_response.fee_msat or 0,
labels=labels,
+28 -11
View File
@@ -26,7 +26,7 @@ from lnbits.core.services.notifications import (
from lnbits.db import Filters
from lnbits.settings import settings
from lnbits.utils.cache import cache
from lnbits.utils.exchange_rates import btc_rates
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
@@ -151,17 +151,34 @@ async def collect_exchange_rates_data() -> None:
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))
cache.set(
f"btc-price-{currency}",
lnbits_rate,
expiry=settings.lnbits_exchange_rate_cache_seconds,
if (
settings.lnbits_price_aggregator_enabled
and settings.lnbits_price_aggregator_url
):
price = await btc_price_from_aggregator(currency)
if price:
cache.set(
f"btc-price-{currency}",
price,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(
{"Aggregator": price}, max_history_size
)
else:
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))
cache.set(
f"btc-price-{currency}",
lnbits_rate,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(
dict(rates), max_history_size
)
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
else:
+5
View File
@@ -362,6 +362,8 @@ class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0)
lnbits_exchange_history_size: int = Field(default=60, ge=0)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
lnbits_price_aggregator_enabled: bool = Field(default=True)
lnbits_price_aggregator_url: str = Field(default="https://price.lnbits.com")
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[
@@ -587,6 +589,7 @@ class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
phoenixd_mnemonic_backup_confirmed: bool = Field(default=False)
class AlbyFundingSource(LNbitsSettings):
@@ -611,6 +614,7 @@ class SparkL2FundingSource(LNbitsSettings):
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_mnemonic: str | None = Field(default=None)
spark_l2_mnemonic_backup_confirmed: bool = Field(default=False)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
@@ -648,6 +652,7 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None)
boltz_mnemonic_backup_confirmed: bool = Field(default=False)
class StrikeFundingSource(LNbitsSettings):
File diff suppressed because one or more lines are too long
@@ -62,12 +62,6 @@ window.app.component('lnbits-admin-exchange-providers', {
mounted() {
this.getExchangeRateHistory()
},
created() {
const hash = window.location.hash.replace('#', '')
if (hash === 'exchange_providers') {
this.showExchangeProvidersTab(hash)
}
},
methods: {
getDefaultSetting(fieldName) {
LNbits.api.getDefaultSetting(fieldName).then(response => {
@@ -127,18 +121,21 @@ window.app.component('lnbits-admin-exchange-providers', {
this.exchangeData.showTickerConversion = true
},
initExchangeChart(data) {
if (this.exchangeRatesChart) {
this.exchangeRatesChart.destroy()
this.exchangeRatesChart = null
}
const xValues = data.map(d =>
this.utils.formatTimestamp(d.timestamp, 'HH:mm')
)
const exchanges = [
...this.formData.lnbits_exchange_rate_providers,
{name: 'LNbits'}
]
const exchanges = this.formData.lnbits_price_aggregator_enabled
? [{name: 'Aggregator'}]
: [...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,
borderWidth: exchange.name === 'LNbits' ? 4 : 2,
tension: 0.4
}))
this.exchangeRatesChart = new Chart(
@@ -148,7 +145,11 @@ window.app.component('lnbits-admin-exchange-providers', {
options: {
plugins: {
legend: {
display: false
display: true
},
title: {
display: true,
text: 'Bitcoin Price History'
}
}
},
@@ -0,0 +1,151 @@
window.app.component('lnbits-admin-funding-seed-backup', {
props: ['active', 'is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding-seed-backup',
data() {
return {
dialog: {
show: false,
step: 1,
seed: '',
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: ''
}
}
},
watch: {
active(isActive) {
if (isActive) {
this.openIfRequired()
}
},
'formData.lnbits_backend_wallet_class'(walletClass, previousWalletClass) {
const source = this.seedBackupSource(walletClass)
if (previousWalletClass && source && this.formData[source.seedField]) {
this.formData[source.confirmField] = false
}
this.openIfRequired()
},
'formData.boltz_mnemonic'() {
this.formData.boltz_mnemonic_backup_confirmed =
this.formData.boltz_mnemonic === this.settings.boltz_mnemonic
? this.settings.boltz_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.phoenixd_mnemonic'() {
this.formData.phoenixd_mnemonic_backup_confirmed =
this.formData.phoenixd_mnemonic === this.settings.phoenixd_mnemonic
? this.settings.phoenixd_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.spark_l2_mnemonic'() {
this.formData.spark_l2_mnemonic_backup_confirmed =
this.formData.spark_l2_mnemonic === this.settings.spark_l2_mnemonic
? this.settings.spark_l2_mnemonic_backup_confirmed
: false
this.openIfRequired()
}
},
computed: {
seedWords() {
return this.dialog.seed
.split(/\s+/)
.filter(Boolean)
.map((word, index) => ({index, word}))
}
},
created() {
this.openIfRequired()
},
methods: {
seedBackupSource(walletClass = this.formData.lnbits_backend_wallet_class) {
if (walletClass === 'BoltzWallet') {
return {
seedField: 'boltz_mnemonic',
confirmField: 'boltz_mnemonic_backup_confirmed'
}
}
if (walletClass === 'PhoenixdWallet') {
return {
seedField: 'phoenixd_mnemonic',
confirmField: 'phoenixd_mnemonic_backup_confirmed'
}
}
if (walletClass === 'SparkL2Wallet') {
return {
seedField: 'spark_l2_mnemonic',
confirmField: 'spark_l2_mnemonic_backup_confirmed'
}
}
},
openIfRequired() {
if (!this.active || !this.isSuperUser) return
const source = this.seedBackupSource()
if (!source) return
const seed = (this.formData[source.seedField] || '').trim()
const confirmed = this.formData[source.confirmField]
if (!seed || confirmed || this.dialog.show) return
this.dialog = {
show: true,
step: 1,
seed,
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: source.confirmField
}
},
prepareChallenge() {
const words = this.dialog.seed.split(/\s+/).filter(Boolean)
const count = Math.min(4, words.length)
const indexes = _.shuffle([...Array(words.length).keys()]).slice(0, count)
this.dialog.challenge = indexes
.sort((a, b) => a - b)
.map(index => ({index, word: words[index]}))
this.dialog.answers = {}
this.dialog.error = ''
this.dialog.step = 2
},
submitChallenge() {
const isValid = this.dialog.challenge.every(({index, word}) => {
const answer = this.dialog.answers[index] || ''
return answer.trim().toLowerCase() === word.toLowerCase()
})
if (!isValid) {
this.dialog.error =
'One or more words are incorrect. Check your backup and try again.'
return
}
const field = this.dialog.confirmField
LNbits.api
.request(
'PATCH',
'/admin/api/v1/settings',
this.g.user.wallets[0].adminkey,
{
[field]: true
}
)
.then(() => {
this.formData[field] = true
this.settings[field] = true
this.dialog.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Seed backup confirmed',
icon: 'check'
})
})
.catch(LNbits.utils.notifyApiError)
}
}
})
@@ -1,5 +1,5 @@
window.app.component('lnbits-admin-funding', {
props: ['is-super-user', 'form-data', 'settings'],
props: ['active', 'is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding',
data() {
return {
+1
View File
@@ -58,6 +58,7 @@
"js/pages/users.js",
"js/pages/account.js",
"js/pages/admin.js",
"js/components/admin/lnbits-admin-funding-seed-backup.js",
"js/components/admin/lnbits-admin-funding.js",
"js/components/admin/lnbits-admin-funding-sources.js",
"js/components/admin/lnbits-admin-fiat-providers.js",
+2 -1
View File
@@ -1,4 +1,5 @@
{% include('components/admin/funding.vue') %} {%
{% include('components/admin/funding_seed_backup.vue') %} {%
include('components/admin/funding.vue') %} {%
include('components/admin/funding_sources.vue') %} {%
include('components/admin/fiat_providers.vue') %} {%
include('components/admin/exchange_providers.vue') %} {%
@@ -1,7 +1,46 @@
<template id="lnbits-admin-exchange-providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<h6 class="q-my-none q-mb-xs">LNbits Price Aggregator</h6>
<p class="q-mb-md text-caption text-grey">
A privacy-friendly, open-source Bitcoin price aggregator maintained by the
LNbits team. Aggregates prices from multiple exchanges and returns a median,
no API keys required.
<a href="https://price.lnbits.com" target="_blank" rel="noopener"
>price.lnbits.com</a
>
&mdash;
<a
href="https://github.com/lnbits/lnbits-price-aggregator"
target="_blank"
rel="noopener"
>GitHub</a
>
</p>
<div class="row q-mb-md items-start">
<div class="col-auto q-mr-md q-mt-sm">
<q-toggle
v-model="formData.lnbits_price_aggregator_enabled"
@update:model-value="formData.touch = null"
label="Use Price Aggregator"
>
</q-toggle>
</div>
<div class="col-12 col-md-7">
<q-input
filled
v-model="formData.lnbits_price_aggregator_url"
type="text"
label="Price Aggregator URL"
hint="Fetch BTC price from this aggregator instead of individual providers below."
:disable="!formData.lnbits_price_aggregator_enabled"
@update:model-value="formData.touch = null"
>
</q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<h6 class="q-my-none q-mb-sm">Bitcoin Price History</h6>
<div class="row">
<div class="col-12 col-md-8">
@@ -53,6 +92,11 @@
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<div class="row q-mt-md">
<div class="col-6">
<q-btn
@@ -60,6 +104,7 @@
label="Add Exchange Provider"
color="primary"
class="q-mb-md"
:disable="formData.lnbits_price_aggregator_enabled"
>
</q-btn>
</div>
@@ -70,12 +115,20 @@
:label="$t('reset_defaults')"
color="primary"
class="float-right"
:disable="formData.lnbits_price_aggregator_enabled"
>
</q-btn>
</div>
</div>
<div class="overflow-auto">
<div
class="overflow-auto"
:style="
formData.lnbits_price_aggregator_enabled
? 'opacity:0.4;pointer-events:none'
: ''
"
>
<q-table
row-key="name"
:rows="formData.lnbits_exchange_rate_providers"
@@ -301,5 +301,11 @@
</div>
</div>
</div>
<lnbits-admin-funding-seed-backup
:active="active"
:is-super-user="isSuperUser"
:form-data="formData"
:settings="settings"
></lnbits-admin-funding-seed-backup>
</q-card-section>
</template>
@@ -0,0 +1,136 @@
<template id="lnbits-admin-funding-seed-backup">
<q-dialog v-model="dialog.show">
<q-card style="width: 760px; max-width: 95vw; border-radius: 8px">
<q-card-section class="q-pb-md">
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-chip
square
class="full-width"
icon="looks_one"
:color="dialog.step === 1 ? 'primary' : 'grey-9'"
text-color="white"
label="Backup"
></q-chip>
</div>
<div class="col-6">
<q-chip
square
class="full-width"
icon="looks_two"
:color="dialog.step === 2 ? 'primary' : 'grey-9'"
text-color="white"
label="Verify"
></q-chip>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="dialog.step === 1">
<div class="row items-center justify-between q-mb-md">
<div>
<div
class="text-subtitle1"
v-text="`${seedWords.length}-word recovery phrase`"
></div>
<div
class="text-caption text-grey-5"
v-text="'Write these words down in order.'"
></div>
</div>
<q-btn
outline
no-caps
color="primary"
:icon="dialog.visible ? 'visibility_off' : 'visibility'"
:label="dialog.visible ? 'Hide words' : 'Show words'"
@click="dialog.visible = !dialog.visible"
></q-btn>
</div>
<div class="row q-col-gutter-sm">
<div
class="col-4 col-md-3"
v-for="word in seedWords"
:key="word.index"
>
<div
class="row items-center no-wrap rounded-borders"
style="
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.035);
"
>
<div
class="text-caption text-grey-5 text-center"
style="
width: 42px;
border-right: 1px solid rgba(255, 255, 255, 0.1);
"
v-text="word.index + 1"
></div>
<div
class="text-body2 text-weight-medium q-px-sm"
style="min-width: 0; overflow-wrap: anywhere"
v-text="dialog.visible ? word.word : '••••••'"
></div>
</div>
</div>
</div>
<div class="row justify-end q-mt-lg">
<q-btn
color="primary"
no-caps
label="I have written it down"
@click="prepareChallenge"
></q-btn>
</div>
</q-card-section>
<q-card-section v-if="dialog.step === 2">
<div class="q-mb-md">
<div class="text-subtitle1" v-text="'Confirm your backup'"></div>
<div
class="text-caption text-grey-5"
v-text="
'Enter the requested words from your written recovery phrase.'
"
></div>
</div>
<div class="row q-col-gutter-md">
<div
class="col-12 col-sm-6"
v-for="word in dialog.challenge"
:key="word.index"
>
<q-input
v-model.trim="dialog.answers[word.index]"
filled
:label="`Word ${word.index + 1}`"
></q-input>
</div>
</div>
<div
class="text-negative q-mt-sm"
v-if="dialog.error"
v-text="dialog.error"
></div>
<div class="row justify-between q-mt-lg">
<q-btn flat no-caps label="Back" @click="dialog.step = 1"></q-btn>
<q-btn
color="primary"
icon="check"
no-caps
label="Confirm backup"
@click="submitChallenge"
></q-btn>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
@@ -6,6 +6,9 @@
:content-inset-level="0.5"
>
<q-card-section>
<q-banner dense rounded class="bg-warning text-black q-mb-md">
These keys should be kept safe, sharing them could risk losing funds.
</q-banner>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
+1
View File
@@ -199,6 +199,7 @@
>
<q-tab-panel name="funding">
<lnbits-admin-funding
:active="tab === 'funding'"
:is-super-user="isSuperUser"
:settings="settings"
:form-data="formData"
+25
View File
@@ -289,7 +289,32 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
return apply_trimmed_mean_filter(all_rates)
async def btc_price_from_aggregator(currency: str) -> float | None:
url = settings.lnbits_price_aggregator_url.rstrip("/")
try:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
r = await client.get(f"{url}/rate/{currency.upper()}", timeout=3)
r.raise_for_status()
data = r.json()
median = data.get("rates", {}).get("median")
if median:
return float(median)
except Exception as e:
logger.warning(f"Failed to fetch price from aggregator {url}: {e}")
return None
async def btc_price(currency: str) -> float:
if (
settings.lnbits_price_aggregator_enabled
and settings.lnbits_price_aggregator_url
):
price = await btc_price_from_aggregator(currency)
if price:
return price
logger.warning("Price aggregator failed, falling back to exchange providers.")
rates = await btc_rates(currency)
if not rates:
logger.warning("Could not fetch any Bitcoin price.")
+921 -138
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -111,6 +111,7 @@
"js/pages/users.js",
"js/pages/account.js",
"js/pages/admin.js",
"js/components/admin/lnbits-admin-funding-seed-backup.js",
"js/components/admin/lnbits-admin-funding.js",
"js/components/admin/lnbits-admin-funding-sources.js",
"js/components/admin/lnbits-admin-fiat-providers.js",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.5"
version = "1.5.6"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
+27
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import pytest
from httpx import AsyncClient
from lnbits.core.crud.settings import get_settings_field, set_settings_field
from lnbits.server import server_restart
from lnbits.settings import Settings
@@ -150,6 +151,15 @@ async def test_admin_partial_reset_restart_and_backup(
async def test_admin_delete_settings_requires_superuser(
client: AsyncClient, superuser_token: str
):
await set_settings_field("lnbits_site_title", "Reset me")
await set_settings_field("lnbits_backend_wallet_class", "BoltzWallet")
await set_settings_field("boltz_mnemonic", "keep boltz seed")
await set_settings_field("boltz_mnemonic_backup_confirmed", True)
await set_settings_field("phoenixd_mnemonic", "keep phoenixd seed")
await set_settings_field("phoenixd_mnemonic_backup_confirmed", True)
await set_settings_field("spark_l2_mnemonic", "keep spark seed")
await set_settings_field("spark_l2_mnemonic_backup_confirmed", True)
server_restart.clear()
response = await client.delete(
"/admin/api/v1/settings",
@@ -157,4 +167,21 @@ async def test_admin_delete_settings_requires_superuser(
)
assert response.status_code == 200
assert server_restart.is_set() is True
assert await get_settings_field("lnbits_site_title") is None
backend_wallet = await get_settings_field("lnbits_backend_wallet_class")
boltz_seed = await get_settings_field("boltz_mnemonic")
boltz_confirmed = await get_settings_field("boltz_mnemonic_backup_confirmed")
phoenixd_seed = await get_settings_field("phoenixd_mnemonic")
phoenixd_confirmed = await get_settings_field("phoenixd_mnemonic_backup_confirmed")
spark_l2_seed = await get_settings_field("spark_l2_mnemonic")
spark_l2_confirmed = await get_settings_field("spark_l2_mnemonic_backup_confirmed")
assert backend_wallet and backend_wallet.value == "BoltzWallet"
assert boltz_seed and boltz_seed.value == "keep boltz seed"
assert boltz_confirmed and boltz_confirmed.value is True
assert phoenixd_seed and phoenixd_seed.value == "keep phoenixd seed"
assert phoenixd_confirmed and phoenixd_confirmed.value is True
assert spark_l2_seed and spark_l2_seed.value == "keep spark seed"
assert spark_l2_confirmed and spark_l2_confirmed.value is True
server_restart.clear()
+4
View File
@@ -275,6 +275,10 @@ async def test_btc_rates_skips_unsupported_and_failing_providers(
@pytest.mark.anyio
async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture):
mocker.patch(
"lnbits.utils.exchange_rates.btc_price_from_aggregator",
AsyncMock(return_value=None),
)
mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[]))
assert await btc_price("usd") == 0.0
+127 -1
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import hashlib
import json
@@ -12,7 +13,7 @@ from Cryptodome.Util.Padding import pad, unpad
from websockets import ServerConnection
from websockets import serve as ws_serve
from lnbits.wallets.nwc import NWCWallet
from lnbits.wallets.nwc import NWCConnection, NWCWallet
from tests.wallets.helpers import (
WalletTest,
build_test_id,
@@ -99,6 +100,8 @@ async def handle( # noqa: C901
event,
)
await websocket.send(json.dumps(["EVENT", sub_id, event]))
elif 23195 in kinds:
assert sub_filter["authors"] == [mock_settings["service_public_key"]]
elif msg[0] == "EVENT":
event = msg[1]
decrypted_content = decrypt_content(
@@ -177,6 +180,129 @@ async def run(data: WalletTest):
await nwcwallet.cleanup()
@pytest.mark.anyio
async def test_nwc_rejects_event_from_unexpected_pubkey(mocker):
async def _noop(*args, **kwargs):
return None
mocker.patch("lnbits.wallets.nwc.NWCConnection._connect_to_relay", new=_noop)
mocker.patch("lnbits.wallets.nwc.NWCConnection._handle_timeouts", new=_noop)
service_private_key = PrivateKey()
service_public_key = service_private_key.public_key.format().hex()[2:]
attacker_private_key = PrivateKey()
attacker_public_key = attacker_private_key.public_key.format().hex()[2:]
account_private_key = PrivateKey()
conn = NWCConnection(
service_public_key,
account_private_key.secret.hex(),
"ws://127.0.0.1:8555",
)
try:
event = {
"kind": 23195,
"content": "{}",
"created_at": int(time.time()),
"tags": [["e", "request-event-id"]],
}
sign_event(attacker_public_key, attacker_private_key.secret.hex(), event)
with pytest.raises(Exception, match="Invalid event signature"):
await conn._on_event_message(["EVENT", "subid", event])
finally:
await conn.close()
@pytest.mark.anyio
async def test_nwc_marks_pending_invoice_settled_only_once():
wallet = NWCWallet.__new__(NWCWallet)
wallet.pending_invoice_details = {"checking-id": {"checking_id": "checking-id"}}
wallet.pending_invoices = ["checking-id"]
wallet.paid_invoices_queue = asyncio.Queue(0)
wallet._mark_invoice_settled("checking-id", source="notification")
wallet._mark_invoice_settled("checking-id", source="notification")
assert wallet.paid_invoices_queue.qsize() == 1
assert await wallet.paid_invoices_queue.get() == "checking-id"
@pytest.mark.anyio
async def test_nwc_registers_notification_subscriptions(mocker):
async def _noop(*args, **kwargs):
return None
mocker.patch("lnbits.wallets.nwc.NWCConnection._connect_to_relay", new=_noop)
mocker.patch("lnbits.wallets.nwc.NWCConnection._handle_timeouts", new=_noop)
service_private_key = PrivateKey()
service_public_key = service_private_key.public_key.format().hex()[2:]
account_private_key = PrivateKey()
conn = NWCConnection(
service_public_key,
account_private_key.secret.hex(),
"ws://127.0.0.1:8555",
)
send_mock = mocker.patch.object(conn, "_send", mocker.AsyncMock())
try:
await conn._subscribe_to_notifications()
assert len(conn.notification_subscription_ids) == 2
assert len(conn.subscriptions) == 2
assert set(conn.subscriptions.keys()) == conn.notification_subscription_ids
assert all(
subscription["method"] == "notification_sub"
and subscription["event_id"] == subscription["sub_id"]
for subscription in conn.subscriptions.values()
)
assert send_mock.await_count == 2
finally:
await conn.close()
@pytest.mark.anyio
async def test_nwc_spreads_fallback_lookups_with_cooldown(mocker):
def _schedule_next_lookup(
invoice: dict[str, object], now: float | None = None
) -> None:
assert now is not None
invoice["next_lookup_at"] = now + 1
wallet = NWCWallet.__new__(NWCWallet)
wallet.shutdown = False
wallet.pending_invoices = ["checking-1", "checking-2"]
wallet.pending_invoice_details = {
"checking-1": {
"checking_id": "checking-1",
"next_lookup_at": 0.0,
"lookup_attempts": 0,
},
"checking-2": {
"checking_id": "checking-2",
"next_lookup_at": 0.0,
"lookup_attempts": 0,
},
}
wallet.pending_invoices_lookup_cooldown = 1.0
wallet._is_shutting_down = lambda: False
wallet._payment_data_is_settled = lambda payment_data: False
wallet._cache_payment_data = lambda *args, **kwargs: None
wallet._schedule_next_lookup = _schedule_next_lookup
wallet.conn = mocker.Mock()
wallet.conn.get_info = mocker.AsyncMock()
wallet.conn.supports_method = mocker.Mock(return_value=True)
wallet.conn.call = mocker.AsyncMock(return_value={"settled_at": None})
sleep_mock = mocker.patch("lnbits.wallets.nwc.asyncio.sleep", mocker.AsyncMock())
await wallet._run_fallback_lookups(100.0)
assert wallet.conn.call.await_count == 2
sleep_mock.assert_awaited_once_with(1.0)
@pytest.mark.anyio
@pytest.mark.parametrize(
"test_data",
Generated
+1 -1
View File
@@ -1288,7 +1288,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.5.5"
version = "1.5.6"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },