From 672a5b3a4dd77f3c603597550d31cb0bba3646fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Wed, 10 Sep 2025 14:52:47 +0200 Subject: [PATCH] fix: pay with nfc endpoint + add perform_withdraw (#3352) --- lnbits/core/services/__init__.py | 3 ++- lnbits/core/services/lnurl.py | 29 +++++++++++++++++++++ lnbits/core/views/lnurl_api.py | 43 ++------------------------------ lnbits/core/views/payment_api.py | 23 +++++++++++++++++ poetry.lock | 8 +++--- pyproject.toml | 2 +- tests/api/test_api.py | 29 +++++++-------------- uv.lock | 8 +++--- 8 files changed, 74 insertions(+), 71 deletions(-) diff --git a/lnbits/core/services/__init__.py b/lnbits/core/services/__init__.py index 2d385cb7..70e49e0b 100644 --- a/lnbits/core/services/__init__.py +++ b/lnbits/core/services/__init__.py @@ -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", diff --git a/lnbits/core/services/lnurl.py b/lnbits/core/services/lnurl.py index fde68b59..5eacf20a 100644 --- a/lnbits/core/services/lnurl.py +++ b/lnbits/core/services/lnurl.py @@ -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: diff --git a/lnbits/core/views/lnurl_api.py b/lnbits/core/views/lnurl_api.py index dc713ea1..ff701bc6 100644 --- a/lnbits/core/views/lnurl_api.py +++ b/lnbits/core/views/lnurl_api.py @@ -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 diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py index bd9ab71c..2d27d8d8 100644 --- a/lnbits/core/views/payment_api.py +++ b/lnbits/core/views/payment_api.py @@ -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.") diff --git a/poetry.lock b/poetry.lock index 522e3208..b8f6a122 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2134,14 +2134,14 @@ valkey = ["valkey (>=6)"] [[package]] name = "lnurl" -version = "0.8.2" +version = "0.8.3" description = "LNURL implementation for Python." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "lnurl-0.8.2-py3-none-any.whl", hash = "sha256:cbfbb5b8d82cc69eb76eb889ef2fe19972b27dd3e60abc4887bc37dd1cbc674e"}, - {file = "lnurl-0.8.2.tar.gz", hash = "sha256:eeea661e54be996629838f5a8b9764ad472494d27d8a74bd6ae23478460332f3"}, + {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 = "e70c42d58a982db371d8489950153d116d9e13ce2e31a7cfbe09687127ba3b67" +content-hash = "6b3b2f3c3163bc7a7bc2029ff6dd67f67c37057d9efbdf866b9d744f9e4ee9a5" diff --git a/pyproject.toml b/pyproject.toml index 4d83ca2a..1ba18a26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "starlette==0.47.1", "httpx==0.27.0", "jinja2==3.1.6", - "lnurl==0.8.2", + "lnurl==0.8.3", "pydantic==1.10.22", "pyqrcode==1.2.1", "shortuuid==1.0.13", diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 1ec28fb7..539ad87f 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -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 diff --git a/uv.lock b/uv.lock index 136b3731..5c025e31 100644 --- a/uv.lock +++ b/uv.lock @@ -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.2" }, + { 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.2" +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/a1/ea/d91146233ad56a855c58811255e162aca015cb39a28e7ce88224a1c13cdf/lnurl-0.8.2.tar.gz", hash = "sha256:eeea661e54be996629838f5a8b9764ad472494d27d8a74bd6ae23478460332f3", size = 17170, upload-time = "2025-09-03T12:58:17.326Z" } +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/0b/04/7190c84f8c54427e75d3a2954abccaa9c56dd7f72a4dba5e5113f6cf411a/lnurl-0.8.2-py3-none-any.whl", hash = "sha256:cbfbb5b8d82cc69eb76eb889ef2fe19972b27dd3e60abc4887bc37dd1cbc674e", size = 17454, upload-time = "2025-09-03T12:58:16.492Z" }, + { 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]]