Compare commits

...
20 Commits
Author SHA1 Message Date
dni ⚡andGitHub 92d3269a85 chore: update to v1.3.0-rc8 (#3359) 2025-09-12 14:29:42 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
183b84c167 build(deps): bump axios from 1.8.2 to 1.12.0 (#3357)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-09-12 12:53:27 +01:00
60f50a71a2 feat: Adds stripe tap to pay flow for TPoS running on Android PoS/phone devices (#3334)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-09-12 12:50:24 +01:00
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
38 changed files with 1141 additions and 670 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}),
)
+2
View File
@@ -139,7 +139,9 @@ async def create_fiat_invoice(
payment_hash=internal_payment.payment_hash,
currency=invoice_data.unit,
memo=invoice_data.memo,
extra=invoice_data.extra or {},
)
if fiat_invoice.failed:
logger.warning(fiat_invoice.error_message)
internal_payment.status = PaymentState.FAILED
+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
+28 -1
View File
@@ -1,10 +1,11 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin
from lnbits.fiat import StripeWallet, get_fiat_provider
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
@@ -16,3 +17,29 @@ fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
)
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider)
@fiat_router.post(
"/{provider}/connection_token",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def connection_token(provider: str):
provider_wallet = await get_fiat_provider(provider)
if provider == "stripe":
if not isinstance(provider_wallet, StripeWallet):
raise HTTPException(
status_code=500, detail="Stripe wallet/provider not configured"
)
try:
tok = await provider_wallet.create_terminal_connection_token()
secret = tok.get("secret")
if not secret:
raise HTTPException(
status_code=502, detail="Stripe returned no connection token"
)
return {"secret": secret}
except Exception as e:
raise HTTPException(
status_code=500, detail="Failed to create connection token"
) from e
+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}
+2 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Coroutine
from typing import TYPE_CHECKING, NamedTuple
from typing import TYPE_CHECKING, Any, NamedTuple
if TYPE_CHECKING:
pass
@@ -106,6 +106,7 @@ class FiatProvider(ABC):
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> Coroutine[None, None, FiatInvoiceResponse]:
pass
+223 -62
View File
@@ -2,10 +2,12 @@ import asyncio
import json
from collections.abc import AsyncGenerator
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from urllib.parse import urlencode
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings
@@ -21,6 +23,34 @@ from .base import (
FiatStatusResponse,
)
FiatMethod = Literal["checkout", "terminal"]
class StripeTerminalOptions(BaseModel):
class Config:
extra = "ignore"
capture_method: Literal["automatic", "manual"] = "automatic"
metadata: dict[str, str] = Field(default_factory=dict)
class StripeCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
line_item_name: str | None = None
class StripeCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None
checkout: StripeCheckoutOptions | None = None
class StripeWallet(FiatProvider):
"""https://docs.stripe.com/api"""
@@ -30,9 +60,9 @@ class StripeWallet(FiatProvider):
self._settings_fields = self._settings_connection_fields()
if not settings.stripe_api_endpoint:
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
if not settings.stripe_api_secret_key:
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
self.headers = {
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
@@ -60,8 +90,10 @@ class StripeWallet(FiatProvider):
r.raise_for_status()
data = r.json()
available_balance = data.get("available", [{}])[0].get("amount", 0)
# pending_balance = data.get("pending", {}).get("amount", 0)
available = data.get("available") or []
available_balance = 0
if available and isinstance(available, list):
available_balance = int(available[0].get("amount", 0))
return FiatStatusResponse(balance=available_balance)
except json.JSONDecodeError:
@@ -76,81 +108,47 @@ class StripeWallet(FiatProvider):
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
amount_cents = int(amount * 100)
form_data = [
("mode", "payment"),
(
"success_url",
settings.stripe_payment_success_url or "https://lnbits.com",
),
("metadata[payment_hash]", payment_hash),
("line_items[0][price_data][currency]", currency.lower()),
("line_items[0][price_data][product_data][name]", memo or "LNbits Invoice"),
("line_items[0][price_data][unit_amount]", amount_cents),
("line_items[0][quantity]", "1"),
]
encoded_data = urlencode(form_data)
opts = self._parse_create_opts(extra or {})
if not opts:
return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options")
try:
headers = self.headers.copy()
headers["Content-Type"] = "application/x-www-form-urlencoded"
r = await self.client.post(
url="/v1/checkout/sessions", headers=headers, content=encoded_data
if opts.fiat_method == "checkout":
return await self._create_checkout_invoice(
amount_cents, currency, payment_hash, memo, opts
)
if opts.fiat_method == "terminal":
return await self._create_terminal_invoice(
amount_cents, currency, payment_hash, opts
)
r.raise_for_status()
data = r.json()
session_id = data.get("id")
if not session_id:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'missing session id'"
)
payment_request = data.get("url")
if not payment_request:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'missing payment URL'"
)
return FiatInvoiceResponse(
ok=True, checking_id=session_id, payment_request=payment_request
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
return FiatInvoiceResponse(
ok=False, error_message=f"Unsupported fiat_method: {opts.fiat_method}"
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Stripe does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
r = await self.client.get(
url=f"/v1/checkout/sessions/{checking_id}",
)
r.raise_for_status()
stripe_id = self._normalize_stripe_id(checking_id)
data = r.json()
payment_status = data.get("payment_status")
if not payment_status:
return FiatPaymentPendingStatus()
if payment_status == "paid":
# todo: handle fee
return FiatPaymentSuccessStatus()
if stripe_id.startswith("cs_"):
r = await self.client.get(f"/v1/checkout/sessions/{stripe_id}")
r.raise_for_status()
return self._status_from_checkout_session(r.json())
expires_at = data.get("expires_at")
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and expires_at < _24_hours_ago.timestamp():
# be defensive: add a 24 hour buffer
return FiatPaymentFailedStatus()
if stripe_id.startswith("pi_"):
r = await self.client.get(f"/v1/payment_intents/{stripe_id}")
r.raise_for_status()
return self._status_from_payment_intent(r.json())
logger.debug(f"Unknown Stripe id prefix: {checking_id}")
return FiatPaymentPendingStatus()
except Exception as exc:
logger.debug(f"Error getting invoice status: {exc}")
return FiatPaymentPendingStatus()
@@ -167,6 +165,169 @@ class StripeWallet(FiatProvider):
value = await mock_queue.get()
yield value
async def create_terminal_connection_token(self) -> dict:
r = await self.client.post("/v1/terminal/connection_tokens")
r.raise_for_status()
return r.json()
async def _create_checkout_invoice(
self,
amount_cents: int,
currency: str,
payment_hash: str,
memo: str | None,
opts: StripeCreateInvoiceOptions,
) -> FiatInvoiceResponse:
co = opts.checkout or StripeCheckoutOptions()
success_url = (
co.success_url
or settings.stripe_payment_success_url
or "https://lnbits.com"
)
line_item_name = co.line_item_name or memo or "LNbits Invoice"
form_data: list[tuple[str, str]] = [
("mode", "payment"),
("success_url", success_url),
("metadata[payment_hash]", payment_hash),
("line_items[0][price_data][currency]", currency.lower()),
("line_items[0][price_data][product_data][name]", line_item_name),
("line_items[0][price_data][unit_amount]", str(amount_cents)),
("line_items[0][quantity]", "1"),
]
form_data += self._encode_metadata("metadata", co.metadata)
try:
r = await self.client.post(
"/v1/checkout/sessions",
headers=self._build_headers_form(),
content=urlencode(form_data),
)
r.raise_for_status()
data = r.json()
session_id, url = data.get("id"), data.get("url")
if not session_id or not url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing id or url"
)
return FiatInvoiceResponse(
ok=True, checking_id=session_id, payment_request=url
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def _create_terminal_invoice(
self,
amount_cents: int,
currency: str,
payment_hash: str,
opts: StripeCreateInvoiceOptions,
) -> FiatInvoiceResponse:
term = opts.terminal or StripeTerminalOptions()
data: dict[str, str] = {
"amount": str(amount_cents),
"currency": currency.lower(),
"payment_method_types[]": "card_present",
"capture_method": term.capture_method,
"metadata[payment_hash]": payment_hash,
"metadata[source]": "lnbits",
}
for k, v in (term.metadata or {}).items():
data[f"metadata[{k}]"] = str(v)
try:
r = await self.client.post("/v1/payment_intents", data=data)
r.raise_for_status()
pi = r.json()
pi_id, client_secret = pi.get("id"), pi.get("client_secret")
if not pi_id or not client_secret:
return FiatInvoiceResponse(
ok=False,
error_message="Error: missing PaymentIntent or client_secret",
)
return FiatInvoiceResponse(
ok=True, checking_id=pi_id, payment_request=client_secret
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
def _normalize_stripe_id(self, checking_id: str) -> str:
"""Remove our internal prefix so Stripe sees a real id."""
return (
checking_id.replace("fiat_stripe_", "", 1)
if checking_id.startswith("fiat_stripe_")
else checking_id
)
def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus:
"""Map a Checkout Session to LNbits fiat status."""
if data.get("payment_status") == "paid":
return FiatPaymentSuccessStatus()
# Consider an expired session a fail (existing 24h rule).
expires_at = data.get("expires_at")
_24h_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and float(expires_at) < _24h_ago.timestamp():
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus:
"""Map a PaymentIntent to LNbits fiat status (card_present friendly)."""
status = pi.get("status")
if status == "succeeded":
return FiatPaymentSuccessStatus()
if status in ("canceled", "payment_failed"):
return FiatPaymentFailedStatus()
if status == "requires_payment_method":
if pi.get("last_payment_error"):
return FiatPaymentFailedStatus()
now_ts = datetime.now(timezone.utc).timestamp()
created_ts = float(pi.get("created") or now_ts)
is_stale = (now_ts - created_ts) > 300
if is_stale:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
def _build_headers_form(self) -> dict[str, str]:
return {**self.headers, "Content-Type": "application/x-www-form-urlencoded"}
def _encode_metadata(
self, prefix: str, md: dict[str, Any]
) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
for k, v in (md or {}).items():
out.append((f"{prefix}[{k}]", str(v)))
return out
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> StripeCreateInvoiceOptions | None:
try:
return StripeCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Stripe options: {e}")
return None
def _settings_connection_fields(self) -> str:
return "-".join(
[str(settings.stripe_api_endpoint), str(settings.stripe_api_secret_key)]
File diff suppressed because one or more lines are too long
+3 -3
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({
+427 -305
View File
File diff suppressed because it is too large Load Diff
+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
+9 -9
View File
@@ -6,7 +6,7 @@
"": {
"name": "lnbits",
"dependencies": {
"axios": "^1.8.2",
"axios": "^1.12.0",
"chart.js": "^4.4.4",
"moment": "^2.30.1",
"nostr-tools": "^2.7.2",
@@ -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"
}
@@ -402,13 +402,13 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.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": {
+2 -2
View File
@@ -15,12 +15,12 @@
"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"
},
"dependencies": {
"axios": "^1.8.2",
"axios": "^1.12.0",
"chart.js": "^4.4.4",
"moment": "^2.30.1",
"qrcode.vue": "^3.4.1",
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-rc8"
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.0rc8"
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]]