Compare commits

...
17 Commits
Author SHA1 Message Date
dni ⚡andGitHub c3252ce4dc chore: update to version v1.3.0-rc7 (#3356) 2025-09-12 14:19:50 +03:00
dni ⚡andGitHub c312d70e63 fix: properly check outgoing payments in phoenixd (#3355) 2025-09-12 11:26:32 +02:00
dni ⚡andGitHub 36fc911b88 fix: js error on qrcode component (#3354) 2025-09-10 16:30:18 +03:00
dni ⚡andGitHub 672a5b3a4d fix: pay with nfc endpoint + add perform_withdraw (#3352) 2025-09-10 14:52:47 +02:00
Vlad StanandGitHub c0b33560bb feat: add search label (#3353) 2025-09-10 15:13:15 +03:00
dni ⚡andGitHub 15b6b1d512 fix: also replace http:// urls in <lnbits-qrcode-lnurl> (#3346) 2025-09-04 07:00:15 +02:00
dni ⚡andGitHub 67c92a79cf chore: update lnurl to v0.8.2 (#3347) 2025-09-03 17:17:48 +03:00
dni ⚡andGitHub 0d4751d6e0 fix: catch error response for lnurl pay (#3344) 2025-09-03 12:36:20 +02:00
dni ⚡andGitHub 213bde61a8 CI: add poetry lock file check job and update poetry lock (#3343) 2025-09-02 19:50:59 +03:00
dni ⚡andGitHub 59553a6e1e chore: update to v1.3.0-rc6 (#3342) 2025-09-02 17:50:20 +02:00
918add5e6e fix: start background tasks when activating extensions at runtime (#3333)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-09-02 17:39:50 +02:00
dni ⚡andGitHub 2375f7bcf8 feat: add copy, download and write NFC to qrcode components (#3335) 2025-09-02 14:48:29 +02:00
dni ⚡andGitHub 5021570f68 feat: add has_connection, listen and receive_queue to websocket_manager (#3330) 2025-09-02 10:02:22 +02:00
dni ⚡andGitHub 0d91d5b5be chore: update to lnurl v0.8.1 (#3341) 2025-09-02 10:01:37 +02:00
Vlad StanandGitHub 49f02a5b77 chore: ignore .venv dir (#3338) 2025-09-01 15:10:56 +03:00
dni ⚡andGitHub 95b9dabb60 fix: docker for boltz-client broke on rc release (#3329) 2025-08-22 20:17:01 +02:00
dni ⚡andGitHub bc0087ec36 ci: fix pip cache is not needed for make (#3331) 2025-08-22 16:04:01 +02:00
33 changed files with 451 additions and 293 deletions
-1
View File
@@ -21,7 +21,6 @@ runs:
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: "pip"
- name: Install uv
uses: astral-sh/setup-uv@v6
+3
View File
@@ -53,3 +53,6 @@ jobs:
with:
make: checkbundle
npm: true
poetry:
uses: ./.github/workflows/poetry.yml
+24
View File
@@ -0,0 +1,24 @@
name: poetry
on:
workflow_call:
inputs:
python-version:
description: "python version"
type: string
default: "3.10"
jobs:
poetry:
name: run poetry install to check lock file
runs-on: "ubuntu-24.04"
steps:
- uses: actions/checkout@v4
- name: set up python ${{ inputs.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ inputs.python-version }}
- name: install poetry
run: |
curl -sSL https://install.python-poetry.org | python3 -
poetry install
+1
View File
@@ -61,3 +61,4 @@ dist
# jetbrains
.idea
.venv
+5 -5
View File
@@ -2,7 +2,7 @@ exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/exte
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@@ -14,16 +14,16 @@ repos:
- id: mixed-line-ending
- id: check-case-conflict
- repo: https://github.com/psf/black
rev: 24.2.0
rev: 25.1.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.2
rev: v0.12.10
hooks:
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v4.0.0-alpha.8"
- repo: https://github.com/rbubley/mirrors-prettier
rev: v3.6.2
hooks:
- id: prettier
types_or: [css, javascript, html, json]
+3
View File
@@ -7,6 +7,9 @@ RUN ls -l /usr/local/bin/boltzd
RUN apt-get update && apt-get -y upgrade && \
apt-get install -y netcat-openbsd
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# Reinstall dependencies just in case (needed for CMD usage)
RUN uv sync --all-extras
+2 -1
View File
@@ -2,7 +2,7 @@ from .funding_source import (
get_balance_delta,
switch_to_voidwallet,
)
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl, perform_withdraw
from .notifications import enqueue_admin_notification, send_payment_notification
from .payments import (
calculate_fiat_amounts,
@@ -57,6 +57,7 @@ __all__ = [
"get_payments_daily_stats",
"get_pr_from_lnurl",
"pay_invoice",
"perform_withdraw",
"send_payment_notification",
"service_fee",
"settle_hold_invoice",
+2
View File
@@ -73,11 +73,13 @@ async def uninstall_extension(ext_id: str):
async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True)
await start_extension_background_work(ext.code)
async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
await stop_extension_background_work(ext_id)
async def stop_extension_background_work(ext_id: str) -> bool:
+29
View File
@@ -7,7 +7,10 @@ from lnurl import (
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
LnurlSuccessResponse,
LnurlWithdrawResponse,
execute_pay_request,
execute_withdraw,
handle,
)
from loguru import logger
@@ -15,10 +18,36 @@ from loguru import logger
from lnbits.core.crud import update_wallet
from lnbits.core.models import CreateLnurlPayment, Wallet
from lnbits.core.models.lnurl import StoredPayLink
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
async def perform_withdraw(lnurl: str, payment_request: str) -> None:
"""
Perform an LNURL withdraw to the given LNURL-withdraw link.
:param lnurl: The LNURL-withdraw link. bech32 or lud17 format.
:param payment_request: The BOLT11 payment request to pay.
:raises LnurlResponseException: If the LNURL-withdraw process fails.
"""
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlWithdrawResponse):
raise LnurlResponseException("Invalid LNURL-withdraw response.")
try:
check_callback_url(res.callback)
except ValueError as exc:
raise LnurlResponseException(f"Invalid callback URL: {exc!s}") from exc
res2 = await execute_withdraw(
res, payment_request, user_agent=settings.user_agent, timeout=10
)
if isinstance(res2, LnurlErrorResponse):
raise LnurlResponseException(res2.reason)
if not isinstance(res2, LnurlSuccessResponse):
raise LnurlResponseException("Invalid LNURL-withdraw success response.")
async def get_pr_from_lnurl(
lnurl: str, amount_msat: int, comment: str | None = None
) -> str:
+5 -6
View File
@@ -277,7 +277,7 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send_data(payment.json(), wallet.inkey)
# await websocket_manager.send(wallet.inkey, payment.json())
# TODO: figure out why we send the balance with the payment here.
# cleaner would be to have a separate message for the balance
# and send it with the id of the wallet so wallets can subscribe to it
@@ -288,12 +288,11 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
"payment": json.loads(payment.json()),
},
)
await websocket_manager.send_data(payment_notification, wallet.inkey)
await websocket_manager.send_data(payment_notification, wallet.adminkey)
await websocket_manager.send_data(
json.dumps({"pending": payment.pending, "status": payment.status}),
await websocket_manager.send(wallet.inkey, payment_notification)
await websocket_manager.send(wallet.adminkey, payment_notification)
await websocket_manager.send(
payment.payment_hash,
json.dumps({"pending": payment.pending, "status": payment.status}),
)
+50 -12
View File
@@ -1,27 +1,65 @@
from fastapi import WebSocket
from asyncio import Queue
from dataclasses import dataclass
from fastapi import WebSocket, WebSocketDisconnect
from loguru import logger
from lnbits.settings import settings
@dataclass
class WebsocketConnection:
item_id: str
websocket: WebSocket
receive_queue: Queue[str]
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: list[WebSocket] = []
self.active_connections: list[WebsocketConnection] = []
async def connect(self, websocket: WebSocket, item_id: str):
async def connect(self, item_id: str, websocket: WebSocket) -> WebsocketConnection:
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
self.active_connections.append(websocket)
conn = WebsocketConnection(
item_id=item_id,
websocket=websocket,
receive_queue=Queue(),
)
self.active_connections.append(conn)
return conn
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def listen(self, conn: WebsocketConnection) -> None:
while settings.lnbits_running:
try:
data = await conn.websocket.receive_text()
logger.debug(f"WS received data from {conn.item_id}: {data}")
conn.receive_queue.put_nowait(data)
except WebSocketDisconnect:
for _conn in self.active_connections:
if _conn.websocket == conn.websocket:
self.active_connections.remove(_conn)
logger.debug(f"WS disconnected from {conn.item_id}")
break # out of the listen and the fastapi route
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
def get_connections(self, item_id: str) -> list[WebsocketConnection]:
conns = []
for conn in self.active_connections:
if conn.item_id == item_id:
conns.append(conn)
return conns
def has_connection(self, item_id: str) -> bool:
return len(self.get_connections(item_id)) > 0
async def send(self, item_id: str, data: str) -> None:
for conn in self.get_connections(item_id):
await conn.websocket.send_text(data)
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id: str, data: str):
return await websocket_manager.send_data(data, item_id)
# deprecated import and use `websocket_manager.send()` instead
async def websocket_updater(item_id: str, data: str) -> None:
return await websocket_manager.send(item_id, data)
+2 -4
View File
@@ -53,8 +53,7 @@
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.adminkey"
:options="{ width: 250 }"
class="rounded-borders"
:show-buttons="false"
></lnbits-qrcode>
</div>
</q-popup-proxy>
@@ -86,8 +85,7 @@
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.inkey"
:options="{ width: 250 }"
class="rounded-borders"
:show-buttons="false"
></lnbits-qrcode>
</div>
</q-popup-proxy>
+7 -13
View File
@@ -328,13 +328,10 @@
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<div v-if="selectedRelease.paymentRequest">
<a :href="'lightning:' + selectedRelease.paymentRequest">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
</a>
<lnbits-qrcode
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
:href="'lightning:' + selectedRelease.paymentRequest"
></lnbits-qrcode>
</div>
<div v-else>
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
@@ -702,13 +699,10 @@
<q-card-section v-if="selectedExtension.payToEnable.showQRCode">
<div class="row q-mt-lg">
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
<a
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
>
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</a>
></lnbits-qrcode>
</div>
<div v-else class="col">
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
+27 -46
View File
@@ -209,16 +209,6 @@
></payment-list>
</q-card-section>
</q-card>
<div id="hiddenQrCodeContainer" style="display: none">
<lnbits-qrcode
v-if="receive.fiatPaymentReq"
:value="receive.fiatPaymentReq"
></lnbits-qrcode>
<lnbits-qrcode
v-else
:value="'lightning:' + (this.receive.paymentReq || '').toUpperCase()"
></lnbits-qrcode>
</div>
</div>
{% if HIDE_API %}
<div class="col-12 col-md-4 q-gutter-y-md">
@@ -277,13 +267,15 @@
:label="$t('drain_funds')"
>
<q-card>
<q-card-section class="text-center">
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
<lnbits-qrcode
:value="lightning:{{wallet.lnurlwithdraw_full}}"
></lnbits-qrcode>
</a>
<p v-text="$t('drain_funds_desc')"></p>
<q-card-section>
<lnbits-qrcode
value="lightning:{{wallet.lnurlwithdraw_full}}"
href="lightning:{{wallet.lnurlwithdraw_full}}"
></lnbits-qrcode>
<p
class="text-center"
v-text="$t('drain_funds_desc')"
></p>
</q-card-section>
</q-card>
</q-expansion-item>
@@ -411,21 +403,15 @@
:label="$t('export_to_phone')"
>
<q-card>
<q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p>
<q-card-section>
<p
class="text-center"
v-text="$t('export_to_phone_desc')"
></p>
<lnbits-qrcode
:value="`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
></lnbits-qrcode>
</q-card-section>
<span v-text="exportWalletQR"></span>
<q-card-actions class="flex-center q-pb-md">
<q-btn
outline
color="grey"
:label="$t('copy_wallet_url')"
@click="copyText(`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`)"
></q-btn>
</q-card-actions>
</q-card>
</q-expansion-item>
</q-card-section>
@@ -862,18 +848,19 @@
v-else-if="receive.paymentReq && receive.lnurl == null"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<div class="text-center q-mb-lg">
<a
v-if="receive.fiatPaymentReq"
:href="receive.fiatPaymentReq"
target="_blank"
>
<div v-html="invoiceQrCode"></div>
</a>
<a v-else :href="'lightning:' + receive.paymentReq">
<div v-html="invoiceQrCode"></div>
</a>
</div>
<lnbits-qrcode
v-if="receive.fiatPaymentReq"
:show-buttons="false"
:href="receive.fiatPaymentReq"
:value="receive.fiatPaymentReq"
>
</lnbits-qrcode>
<lnbits-qrcode
v-else
:href="'lightning:' + receive.paymentReq"
:value="'lightning:' + receive.paymentReq"
>
</lnbits-qrcode>
<div class="text-center">
<h3 class="q-my-md">
<span v-text="formattedAmount"></span>
@@ -898,12 +885,6 @@
</div>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(receive.fiatPaymentReq || receive.paymentReq)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
+2 -41
View File
@@ -6,12 +6,8 @@ from fastapi import (
Depends,
HTTPException,
)
from lnurl import (
LnurlResponseException,
LnurlSuccessResponse,
)
from lnurl import LnurlResponseException
from lnurl import execute_login as lnurlauth
from lnurl import execute_withdraw as lnurl_withdraw
from lnurl import handle as lnurl_handle
from lnurl.models import (
LnurlAuthResponse,
@@ -22,7 +18,7 @@ from lnurl.models import (
)
from loguru import logger
from lnbits.core.models import CreateLnurlWithdraw, Payment
from lnbits.core.models import Payment
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.decorators import (
WalletTypeInfo,
@@ -138,38 +134,3 @@ async def api_payments_pay_lnurl(
)
return payment
@lnurl_router.post(
"/api/v1/payments/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK
)
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: CreateLnurlWithdraw,
) -> LnurlErrorResponse | LnurlSuccessResponse:
if not lnurl_data.lnurl_w.lud17:
return LnurlErrorResponse(reason="LNURL-withdraw lud17 not provided.")
try:
url = lnurl_data.lnurl_w.lud17
res = await lnurl_handle(url, user_agent=settings.user_agent, timeout=10)
except (LnurlResponseException, Exception) as exc:
return LnurlErrorResponse(reason=str(exc))
if not isinstance(res, LnurlWithdrawResponse):
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
try:
check_callback_url(res.callback)
except ValueError as exc:
return LnurlErrorResponse(reason=f"Invalid callback URL: {exc!s}")
try:
res2 = await lnurl_withdraw(
res, payment_request, user_agent=settings.user_agent, timeout=10
)
except (LnurlResponseException, Exception) as exc:
logger.warning(exc)
return LnurlErrorResponse(reason=str(exc))
if not isinstance(res2, LnurlSuccessResponse | LnurlErrorResponse):
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
return res2
+23
View File
@@ -19,6 +19,7 @@ from lnbits.core.crud.payments import (
from lnbits.core.models import (
CancelInvoice,
CreateInvoice,
CreateLnurlWithdraw,
DecodePayment,
KeyType,
Payment,
@@ -29,6 +30,7 @@ from lnbits.core.models import (
PaymentHistoryPoint,
PaymentWalletStats,
SettleInvoice,
SimpleStatus,
)
from lnbits.core.models.users import User
from lnbits.db import Filters, Page
@@ -59,6 +61,7 @@ from ..services import (
fee_reserve_total,
get_payments_daily_stats,
pay_invoice,
perform_withdraw,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
@@ -359,3 +362,23 @@ async def api_payments_cancel(
detail="Payment does not exist or does not belong to this wallet.",
)
return await cancel_hold_invoice(payment)
@payment_router.post("/{payment_request}/pay-with-nfc")
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: CreateLnurlWithdraw,
) -> SimpleStatus:
if not lnurl_data.lnurl_w.lud17:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="LNURL-withdraw lud17 not provided.",
)
try:
await perform_withdraw(lnurl_data.lnurl_w.lud17, payment_request)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
return SimpleStatus(success=True, message="Payment sent with NFC.")
+7 -20
View File
@@ -1,33 +1,20 @@
from fastapi import (
APIRouter,
WebSocket,
WebSocketDisconnect,
)
from fastapi import APIRouter, WebSocket
from lnbits.settings import settings
from ..services import (
websocket_manager,
websocket_updater,
)
from ..services import websocket_manager
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
@websocket_router.websocket("/{item_id}")
async def websocket_connect(websocket: WebSocket, item_id: str):
await websocket_manager.connect(websocket, item_id)
try:
while settings.lnbits_running:
await websocket.receive_text()
except WebSocketDisconnect:
websocket_manager.disconnect(websocket)
async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
conn = await websocket_manager.connect(item_id, websocket)
await websocket_manager.listen(conn)
@websocket_router.post("/{item_id}")
async def websocket_update_post(item_id: str, data: str):
try:
await websocket_updater(item_id, data)
await websocket_manager.send(item_id, data)
return {"sent": True, "data": data}
except Exception:
return {"sent": False, "data": data}
@@ -36,7 +23,7 @@ async def websocket_update_post(item_id: str, data: str):
@websocket_router.get("/{item_id}/{data}")
async def websocket_update_get(item_id: str, data: str):
try:
await websocket_updater(item_id, data)
await websocket_manager.send(item_id, data)
return {"sent": True, "data": data}
except Exception:
return {"sent": False, "data": data}
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -115,6 +115,7 @@ window.localisation.en = {
payment_check: 'Check payment',
not_enough_funds: 'Not enough funds!',
search_by_tag_memo_amount: 'Search by tag, memo, amount',
search: 'Search',
invoice_waiting: 'Invoice waiting to be paid',
payment_received: 'Payment Received',
payment_sent: 'Payment Sent',
@@ -24,7 +24,11 @@ window.app.component('lnbits-qrcode-lnurl', {
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
this.lnurl = `lightning:${bech32.toUpperCase()}`
} else if (this.tab == 'lud17') {
this.lnurl = this.url.replace('https://', this.prefix + '://')
if (this.url.startsWith('http://')) {
this.lnurl = this.url.replace('http://', this.prefix + '://')
} else {
this.lnurl = this.url.replace('https://', this.prefix + '://')
}
}
this.$emit('update:lnurl', this.lnurl)
}
+113 -9
View File
@@ -9,19 +9,123 @@ window.app.component('lnbits-qrcode', {
type: String,
required: true
},
options: Object
nfc: {
type: Boolean,
default: false
},
showButtons: {
type: Boolean,
default: true
},
href: {
type: String,
default: ''
},
margin: {
type: Number,
default: 3
},
maxWidth: {
type: Number,
default: 450
},
logo: {
type: String,
default: LNBITS_QR_LOGO
}
},
data() {
return {
custom: {
margin: 3,
width: 350,
size: 350,
logo: LNBITS_QR_LOGO
}
nfcTagWriting: false,
nfcSupported: typeof NDEFReader != 'undefined'
}
},
created() {
this.custom = {...this.custom, ...this.options}
methods: {
clickQrCode(event) {
if (this.href === '') {
this.copyText(this.value)
event.preventDefault()
event.stopPropagation()
return false
}
},
async writeNfcTag() {
try {
if (!this.nfcSupported) {
throw {
toString: function () {
return 'NFC not supported on this device or browser.'
}
}
}
const ndef = new NDEFReader()
this.nfcTagWriting = true
this.$q.notify({
message: 'Tap your NFC tag to write the LNURL-withdraw link to it.'
})
await ndef.write({
records: [{recordType: 'url', data: this.value, lang: 'en'}]
})
this.nfcTagWriting = false
this.$q.notify({
type: 'positive',
message: 'NFC tag written successfully.'
})
} catch (error) {
this.nfcTagWriting = false
this.$q.notify({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
}
},
downloadSVG() {
const filename = 'qrcode.svg'
const svg = this.$refs.qrCode.$el
if (!svg) {
console.error('SVG element not found')
return
}
// Serialize the SVG content
const serializer = new XMLSerializer()
let source = serializer.serializeToString(svg)
// Add SVG namespace if not present
if (!source.match(/^<svg[^>]+xmlns="http:\/\/www\.w3\.org\/2000\/svg"/)) {
source = source.replace(
/^<svg/,
'<svg xmlns="http://www.w3.org/2000/svg"'
)
}
// Add XML declaration
source = '<?xml version="1.0" standalone="no"?>\n' + source
// Convert to Blob and trigger download
const blob = new Blob([source], {type: 'image/svg+xml;charset=utf-8'})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
// Cleanup
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
},
mounted() {
this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px'
this.$refs.qrCode.$el.setAttribute('width', '100%')
this.$refs.qrCode.$el.removeAttribute('height')
}
})
+30 -29
View File
@@ -154,7 +154,7 @@ function confettiStars() {
setTimeout(shoot, 200)
}
!(function (t, e) {
!(function t(e, n, a, i) {
;(!(function t(e, n, a, i) {
var o = !!(
e.Worker &&
e.Blob &&
@@ -248,12 +248,12 @@ function confettiStars() {
function e(e, n) {
t.postMessage({options: e || {}, callback: n})
}
;(t.init = function (e) {
;((t.init = function (e) {
var n = e.transferControlToOffscreen()
t.postMessage({canvas: n}, [n])
}),
(t.fire = function (n, a, i) {
if (g) return e(n, null), g
if (g) return (e(n, null), g)
var o = Math.random().toString(36).slice(2)
return (g = l(function (a) {
function r(e) {
@@ -264,15 +264,15 @@ function confettiStars() {
i(),
a())
}
t.addEventListener('message', r),
;(t.addEventListener('message', r),
e(n, o),
(m[o] = r.bind(null, {data: {callback: o}}))
(m[o] = r.bind(null, {data: {callback: o}})))
}))
}),
(t.reset = function () {
for (var e in (t.postMessage({reset: !0}), m))
m[e](), delete m[e]
})
(m[e](), delete m[e])
}))
})(h)
}
return h
@@ -328,12 +328,12 @@ function confettiStars() {
)
}
function k(t) {
;(t.width = document.documentElement.clientWidth),
(t.height = document.documentElement.clientHeight)
;((t.width = document.documentElement.clientWidth),
(t.height = document.documentElement.clientHeight))
}
function I(t) {
var e = t.getBoundingClientRect()
;(t.width = e.width), (t.height = e.height)
;((t.width = e.width), (t.height = e.height))
}
function T(t, e, n, o, r) {
var c,
@@ -342,10 +342,10 @@ function confettiStars() {
d = t.getContext('2d'),
f = l(function (e) {
function l() {
;(c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e()
;((c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e())
}
;(c = b.frame(function e() {
!a ||
;((c = b.frame(function e() {
;(!a ||
(o.width === i.width && o.height === i.height) ||
((o.width = t.width = i.width), (o.height = t.height = i.height)),
o.width ||
@@ -354,7 +354,7 @@ function confettiStars() {
d.clearRect(0, 0, o.width, o.height),
(u = u.filter(function (t) {
return (function (t, e) {
;(e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
;((e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
(e.y += Math.sin(e.angle2D) * e.velocity + e.gravity),
(e.wobble += 0.1),
(e.velocity *= e.decay),
@@ -363,7 +363,7 @@ function confettiStars() {
(e.tiltCos = Math.cos(e.tiltAngle)),
(e.random = Math.random() + 5),
(e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)),
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble))
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble)))
var n = e.tick++ / e.totalTicks,
a = e.x + e.random * e.tiltCos,
i = e.y + e.random * e.tiltSin,
@@ -393,12 +393,12 @@ function confettiStars() {
2 * Math.PI
)
: (function (t, e, n, a, i, o, r, l, c) {
t.save(),
;(t.save(),
t.translate(e, n),
t.rotate(o),
t.scale(a, i),
t.arc(0, 0, 1, r, l, c),
t.restore()
t.restore())
})(
t,
e.x,
@@ -420,18 +420,18 @@ function confettiStars() {
})(d, t)
})).length
? (c = b.frame(e))
: l()
: l())
})),
(s = l)
(s = l))
})
return {
addFettis: function (t) {
return (u = u.concat(t)), f
return ((u = u.concat(t)), f)
},
canvas: t,
promise: f,
reset: function () {
c && b.cancel(c), s && s()
;(c && b.cancel(c), s && s())
}
}
}
@@ -466,7 +466,7 @@ function confettiStars() {
k = p(e, 'scalar'),
I = (function (t) {
var e = p(t, 'origin', Object)
return (e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e
return ((e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e)
})(e),
E = d,
S = [],
@@ -531,7 +531,7 @@ function confettiStars() {
return l(function (t) {
t()
})
i && a
;(i && a
? (t = a.canvas)
: i &&
!t &&
@@ -547,7 +547,7 @@ function confettiStars() {
)
})(g)),
document.body.appendChild(t)),
r && !d && u(t)
r && !d && u(t))
var m = {width: t.width, height: t.height}
function b() {
if (s) {
@@ -564,9 +564,9 @@ function confettiStars() {
m.width = m.height = null
}
function v() {
;(a = null),
;((a = null),
r && e.removeEventListener('resize', b),
i && t && (document.body.removeChild(t), (t = null), (d = !1))
i && t && (document.body.removeChild(t), (t = null), (d = !1)))
}
return (
s && !d && s.init(t),
@@ -578,12 +578,13 @@ function confettiStars() {
}
return (
(g.reset = function () {
s && s.reset(), a && a.reset()
;(s && s.reset(), a && a.reset())
}),
g
)
}
;(n.exports = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E)
;((n.exports = E(null, {useWorker: !0, resize: !0})),
(n.exports.create = E))
})(
(function () {
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
@@ -591,5 +592,5 @@ function confettiStars() {
e,
!1
),
(t.confetti = e.exports)
(t.confetti = e.exports))
})(window, {})
+1 -8
View File
@@ -45,7 +45,6 @@ window.WalletPageLogic = {
payment_hash: null
}
},
invoiceQrCode: '',
disclaimerDialog: {
show: false,
location: window.location
@@ -313,13 +312,6 @@ window.WalletPageLogic = {
})
}
}
// Hack as rendering in dialog causes reactivity issues. Does speed up, as only rendering lnbits-qrcode once.
this.$nextTick(() => {
this.invoiceQrCode = document.getElementById(
'hiddenQrCodeContainer'
).innerHTML
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
@@ -583,6 +575,7 @@ window.WalletPageLogic = {
}
}
})
.catch(LNbits.utils.notifyApiError)
},
authLnurl() {
const dismissAuthMsg = Quasar.Notify.create({
+79 -52
View File
@@ -617,21 +617,65 @@
</template>
<template id="lnbits-qrcode">
<div class="qrcode__wrapper">
<qrcode-vue
:value="value"
level="Q"
render-as="svg"
:margin="custom.margin"
:size="custom.width"
class="rounded-borders"
></qrcode-vue>
<img
v-if="custom.logo"
class="qrcode__image"
:src="custom.logo"
alt="qrcode icon"
/>
<div
class="qrcode__outer"
:style="`margin: 13px auto; max-width: ${maxWidth}px`"
>
<div ref="qrWrapper" class="qrcode__wrapper">
<a
:href="href"
:title="href === '' ? value : href"
@click="clickQrCode"
class="no-link full-width"
>
<qrcode-vue
ref="qrCode"
:value="value"
:margin="margin"
:size="size"
level="Q"
render-as="svg"
class="rounded-borders q-mb-sm"
>
<q-tooltip :model-value="href === '' ? value : href"></q-tooltip>
</qrcode-vue>
</a>
<img
:src="logo"
class="qrcode__image"
alt="qrcode icon"
style="pointer-events: none"
/>
</div>
<div
v-if="showButtons"
class="qrcode__buttons row q-gutter-x-sm"
style="justify-content: flex-end"
>
<q-btn
v-if="nfc && nfcSupported"
:disabled="nfcTagWriting"
flat
dense
class="text-grey"
icon="nfc"
@click="writeNfcTag"
>
<q-tooltip>Write NFC Tag</q-tooltip>
</q-btn>
<q-btn flat dense class="text-grey" icon="download" @click="downloadSVG">
<q-tooltip>Download SVG</q-tooltip>
</q-btn>
<q-btn
flat
dense
class="text-grey"
@click="copyText(value)"
icon="content_copy"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
</div>
</div>
</template>
@@ -640,17 +684,16 @@
<q-tabs
v-model="tab"
dense
class="text-grey q-mb-md"
class="text-grey"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
inline-label
>
<q-tab name="bech32" icon="qr_code" label="bech32"></q-tab>
<q-tab name="lud17" icon="link" label="url (lud17)"></q-tab>
</q-tabs>
<lnbits-qrcode :value="lnurl" class="rounded-borders"></lnbits-qrcode>
<lnbits-qrcode :value="lnurl" nfc="true"></lnbits-qrcode>
</div>
</template>
@@ -1044,42 +1087,22 @@
<div
v-if="props.row.isIn && props.row.isPending && props.row.bolt11"
class="text-center q-my-lg"
>
<div v-if="props.row.extra.fiat_payment_request">
<a
<lnbits-qrcode
:value="props.row.extra.fiat_payment_request"
:href="props.row.extra.fiat_payment_request"
target="_blank"
>
<lnbits-qrcode
:value="props.row.extra.fiat_payment_request"
></lnbits-qrcode>
</a>
:show-buttons="false"
></lnbits-qrcode>
</div>
<div v-else>
<a :href="'lightning:' + props.row.bolt11">
<lnbits-qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()"
></lnbits-qrcode>
</a>
<lnbits-qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()"
:href="'lightning:' + props.row.bolt11"
></lnbits-qrcode>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="row q-gutter-x-sm">
<q-btn
v-if="
props.row.isIn && props.row.isPending && props.row.bolt11
"
outline
color="grey"
@click="
copyText(
props.row.extra.fiat_payment_request || props.row.bolt11
)
"
:label="$t('copy_invoice')"
></q-btn>
<div class="row q-mt-md">
<q-btn
outline
color="grey"
@@ -1196,9 +1219,13 @@
:endpoint="endpoint"
>
<template v-slot:actions>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</template>
</lnbits-extension-settings-form>
</q-card>
@@ -1282,10 +1309,10 @@
<q-dialog v-model="showQRDialog">
<q-card class="q-pa-md">
<q-card-section>
<lnbits-qrcode :value="qrValue" :size="200"></lnbits-qrcode>
<lnbits-qrcode :value="qrValue"></lnbits-qrcode>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" v-close-popup />
<q-btn flat :label="$t('close')" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
+2 -6
View File
@@ -226,9 +226,7 @@ class PhoenixdWallet(Wallet):
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(
f"/payments/incoming/{checking_id}?all=true&limit=1000"
)
r = await self.client.get(f"/payments/incoming/{checking_id}")
if r.is_error:
if r.status_code == 404:
# invoice does not exist in phoenixd, so it was never paid
@@ -260,9 +258,7 @@ class PhoenixdWallet(Wallet):
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(
f"/payments/outgoing/{checking_id}?all=true&limit=1000"
)
r = await self.client.get(f"/payments/outgoingbyhash/{checking_id}")
if r.is_error:
if r.status_code == 404:
# payment does not exist in phoenixd, so it was never paid
+4 -4
View File
@@ -23,7 +23,7 @@
"devDependencies": {
"concat": "^1.0.3",
"minify": "^9.2.0",
"prettier": "^3.3.3",
"prettier": "^3.6.2",
"pyright": "1.1.289",
"sass": "^1.78.0"
}
@@ -1222,9 +1222,9 @@
}
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
+1 -1
View File
@@ -15,7 +15,7 @@
"devDependencies": {
"concat": "^1.0.3",
"minify": "^9.2.0",
"prettier": "^3.3.3",
"prettier": "^3.6.2",
"pyright": "1.1.289",
"sass": "^1.78.0"
},
Generated
+4 -4
View File
@@ -2134,14 +2134,14 @@ valkey = ["valkey (>=6)"]
[[package]]
name = "lnurl"
version = "0.8.0"
version = "0.8.3"
description = "LNURL implementation for Python."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "lnurl-0.8.0-py3-none-any.whl", hash = "sha256:260be16dbcc45fd7dd937f781bd79c9fdaf38b4bb04d6e13a1820f24eac9389c"},
{file = "lnurl-0.8.0.tar.gz", hash = "sha256:fa970f7d343caae7435848c592dd223d7ac8120e337d2d5e2d3ea23a0f8f84ef"},
{file = "lnurl-0.8.3-py3-none-any.whl", hash = "sha256:670cdeaef2c55de986dad89126ab58275d5199ba6554a93d9965d1e162080c2a"},
{file = "lnurl-0.8.3.tar.gz", hash = "sha256:8ca73af84fb9ee36a184d731d165f289ba7bc6260d4dadb2b6cf24f381c3afba"},
]
[package.dependencies]
@@ -4592,4 +4592,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "2cd12b894b669ffac32a2584e899fcc6116c625b5b59d16586fe5ec1b1edc5f0"
content-hash = "6b3b2f3c3163bc7a7bc2029ff6dd67f67c37057d9efbdf866b9d744f9e4ee9a5"
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.3.0-rc5"
version = "1.3.0-rc7"
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" }]
@@ -14,7 +14,7 @@ dependencies = [
"starlette==0.47.1",
"httpx==0.27.0",
"jinja2==3.1.6",
"lnurl==0.8.0",
"lnurl==0.8.3",
"pydantic==1.10.22",
"pyqrcode==1.2.1",
"shortuuid==1.0.13",
+9 -20
View File
@@ -1,5 +1,4 @@
import hashlib
from http import HTTPStatus
from json import JSONDecodeError
from unittest.mock import AsyncMock, Mock
@@ -591,31 +590,26 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"minWithdrawable": 1000,
"maxWithdrawable": 1_500_000,
},
{
"status": "OK",
},
{
"status": "OK",
},
{"status": "OK"},
{"success": True, "message": "Payment sent with NFC."},
),
# Error loading LNURL request
(
"error_loading_lnurl",
None,
{
"status": "ERROR",
"reason": "Error loading callback request",
"detail": "Error loading callback request",
},
),
# LNURL response with error status
(
{
"status": "ERROR",
"reason": "Invalid LNURL-withdraw response.",
},
None,
{
"status": "ERROR",
"reason": "Invalid LNURL-withdraw response.",
"detail": "Invalid LNURL-withdraw response.",
},
),
# Invalid LNURL-withdraw pay request
@@ -629,8 +623,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
},
None,
{
"status": "ERROR",
"reason": "Invalid LNURL-withdraw response.",
"detail": "Invalid LNURL-withdraw response.",
},
),
# Error loading callback request
@@ -644,8 +637,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
},
"error_loading_callback",
{
"status": "ERROR",
"reason": "Error loading callback request",
"detail": "Error loading callback request",
},
),
# Callback response with error status
@@ -662,8 +654,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"reason": "Callback failed",
},
{
"status": "ERROR",
"reason": "Callback failed",
"detail": "Callback failed",
},
),
# Unexpected exception during LNURL response JSON parsing
@@ -671,8 +662,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"exception_in_lnurl_response_json",
None,
{
"status": "ERROR",
"reason": "Invalid JSON response from https://example.com/lnurl",
"detail": "Invalid JSON response from https://example.com/lnurl",
},
),
],
@@ -768,7 +758,6 @@ async def test_api_payment_pay_with_nfc(
json={"lnurl_w": lnurl},
)
assert response.status_code == HTTPStatus.OK
assert response.json() == expected_response
@@ -2474,7 +2474,7 @@
},
"phoenixd": {
"get_payment_status_endpoint": {
"uri": "/payments/outgoing/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"uri": "/payments/outgoingbyhash/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
"headers": {
"Authorization": "Basic OmYxNzFiYTAyMmE3NjRlNjc5ZWVmOTUwYjIxZmIxYzA0ZjE3MWJhMDIyYTc2NGU2NzllZWY5NTBiMjFmYjFjMDQ=",
"User-Agent": "LNbits/Tests"
Generated
+5 -5
View File
@@ -1260,7 +1260,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.3.0rc5"
version = "1.3.0rc7"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1363,7 +1363,7 @@ requires-dist = [
{ name = "itsdangerous", specifier = "==2.2.0" },
{ name = "jinja2", specifier = "==3.1.6" },
{ name = "jsonpath-ng", specifier = "==1.7.0" },
{ name = "lnurl", specifier = "==0.8.0" },
{ name = "lnurl", specifier = "==0.8.3" },
{ name = "loguru", specifier = "==0.7.3" },
{ name = "nostr-sdk", specifier = "==0.42.1" },
{ name = "packaging", specifier = "==25.0" },
@@ -1418,7 +1418,7 @@ dev = [
[[package]]
name = "lnurl"
version = "0.8.0"
version = "0.8.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bech32" },
@@ -1429,9 +1429,9 @@ dependencies = [
{ name = "pycryptodomex" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/9e4f4ce76c50825b5ae4935affa877941f41ce8e44ef0f9c5a6757c1243f/lnurl-0.8.0.tar.gz", hash = "sha256:fa970f7d343caae7435848c592dd223d7ac8120e337d2d5e2d3ea23a0f8f84ef", size = 17008, upload-time = "2025-08-21T12:30:11.315Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/dc/29cce6bb8688622c7f5f6db4355c54b5c87bb05be58e49374fd2c6f4b0f2/lnurl-0.8.3.tar.gz", hash = "sha256:8ca73af84fb9ee36a184d731d165f289ba7bc6260d4dadb2b6cf24f381c3afba", size = 17171, upload-time = "2025-09-08T13:30:56.636Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/16/fdfb77478b95b7863d7822683a1fed34c0ef66bc857d8ebc52687b5f3833/lnurl-0.8.0-py3-none-any.whl", hash = "sha256:260be16dbcc45fd7dd937f781bd79c9fdaf38b4bb04d6e13a1820f24eac9389c", size = 17280, upload-time = "2025-08-21T12:30:10.32Z" },
{ url = "https://files.pythonhosted.org/packages/fd/99/e400734afb7469a0cfa661259de2bb66417d6c3f14361444a010e9d186ee/lnurl-0.8.3-py3-none-any.whl", hash = "sha256:670cdeaef2c55de986dad89126ab58275d5199ba6554a93d9965d1e162080c2a", size = 17459, upload-time = "2025-09-08T13:30:55.691Z" },
]
[[package]]