fix: pay with nfc endpoint + add perform_withdraw (#3352)

This commit is contained in:
dni ⚡ 2025-09-10 14:52:47 +02:00 committed by GitHub
parent c0b33560bb
commit 672a5b3a4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 74 additions and 71 deletions

View file

@ -2,7 +2,7 @@ from .funding_source import (
get_balance_delta, get_balance_delta,
switch_to_voidwallet, 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 .notifications import enqueue_admin_notification, send_payment_notification
from .payments import ( from .payments import (
calculate_fiat_amounts, calculate_fiat_amounts,
@ -57,6 +57,7 @@ __all__ = [
"get_payments_daily_stats", "get_payments_daily_stats",
"get_pr_from_lnurl", "get_pr_from_lnurl",
"pay_invoice", "pay_invoice",
"perform_withdraw",
"send_payment_notification", "send_payment_notification",
"service_fee", "service_fee",
"settle_hold_invoice", "settle_hold_invoice",

View file

@ -7,7 +7,10 @@ from lnurl import (
LnurlPayActionResponse, LnurlPayActionResponse,
LnurlPayResponse, LnurlPayResponse,
LnurlResponseException, LnurlResponseException,
LnurlSuccessResponse,
LnurlWithdrawResponse,
execute_pay_request, execute_pay_request,
execute_withdraw,
handle, handle,
) )
from loguru import logger from loguru import logger
@ -15,10 +18,36 @@ from loguru import logger
from lnbits.core.crud import update_wallet from lnbits.core.crud import update_wallet
from lnbits.core.models import CreateLnurlPayment, Wallet from lnbits.core.models import CreateLnurlPayment, Wallet
from lnbits.core.models.lnurl import StoredPayLink from lnbits.core.models.lnurl import StoredPayLink
from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis 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( async def get_pr_from_lnurl(
lnurl: str, amount_msat: int, comment: str | None = None lnurl: str, amount_msat: int, comment: str | None = None
) -> str: ) -> str:

View file

@ -6,12 +6,8 @@ from fastapi import (
Depends, Depends,
HTTPException, HTTPException,
) )
from lnurl import ( from lnurl import LnurlResponseException
LnurlResponseException,
LnurlSuccessResponse,
)
from lnurl import execute_login as lnurlauth from lnurl import execute_login as lnurlauth
from lnurl import execute_withdraw as lnurl_withdraw
from lnurl import handle as lnurl_handle from lnurl import handle as lnurl_handle
from lnurl.models import ( from lnurl.models import (
LnurlAuthResponse, LnurlAuthResponse,
@ -22,7 +18,7 @@ from lnurl.models import (
) )
from loguru import logger 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.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
@ -138,38 +134,3 @@ async def api_payments_pay_lnurl(
) )
return payment 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

View file

@ -19,6 +19,7 @@ from lnbits.core.crud.payments import (
from lnbits.core.models import ( from lnbits.core.models import (
CancelInvoice, CancelInvoice,
CreateInvoice, CreateInvoice,
CreateLnurlWithdraw,
DecodePayment, DecodePayment,
KeyType, KeyType,
Payment, Payment,
@ -29,6 +30,7 @@ from lnbits.core.models import (
PaymentHistoryPoint, PaymentHistoryPoint,
PaymentWalletStats, PaymentWalletStats,
SettleInvoice, SettleInvoice,
SimpleStatus,
) )
from lnbits.core.models.users import User from lnbits.core.models.users import User
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
@ -59,6 +61,7 @@ from ..services import (
fee_reserve_total, fee_reserve_total,
get_payments_daily_stats, get_payments_daily_stats,
pay_invoice, pay_invoice,
perform_withdraw,
settle_hold_invoice, settle_hold_invoice,
update_pending_payment, update_pending_payment,
update_pending_payments, update_pending_payments,
@ -359,3 +362,23 @@ async def api_payments_cancel(
detail="Payment does not exist or does not belong to this wallet.", detail="Payment does not exist or does not belong to this wallet.",
) )
return await cancel_hold_invoice(payment) 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.")

8
poetry.lock generated
View file

@ -2134,14 +2134,14 @@ valkey = ["valkey (>=6)"]
[[package]] [[package]]
name = "lnurl" name = "lnurl"
version = "0.8.2" version = "0.8.3"
description = "LNURL implementation for Python." description = "LNURL implementation for Python."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "lnurl-0.8.2-py3-none-any.whl", hash = "sha256:cbfbb5b8d82cc69eb76eb889ef2fe19972b27dd3e60abc4887bc37dd1cbc674e"}, {file = "lnurl-0.8.3-py3-none-any.whl", hash = "sha256:670cdeaef2c55de986dad89126ab58275d5199ba6554a93d9965d1e162080c2a"},
{file = "lnurl-0.8.2.tar.gz", hash = "sha256:eeea661e54be996629838f5a8b9764ad472494d27d8a74bd6ae23478460332f3"}, {file = "lnurl-0.8.3.tar.gz", hash = "sha256:8ca73af84fb9ee36a184d731d165f289ba7bc6260d4dadb2b6cf24f381c3afba"},
] ]
[package.dependencies] [package.dependencies]
@ -4592,4 +4592,4 @@ migration = ["psycopg2-binary"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<3.13" python-versions = ">=3.10,<3.13"
content-hash = "e70c42d58a982db371d8489950153d116d9e13ce2e31a7cfbe09687127ba3b67" content-hash = "6b3b2f3c3163bc7a7bc2029ff6dd67f67c37057d9efbdf866b9d744f9e4ee9a5"

View file

@ -14,7 +14,7 @@ dependencies = [
"starlette==0.47.1", "starlette==0.47.1",
"httpx==0.27.0", "httpx==0.27.0",
"jinja2==3.1.6", "jinja2==3.1.6",
"lnurl==0.8.2", "lnurl==0.8.3",
"pydantic==1.10.22", "pydantic==1.10.22",
"pyqrcode==1.2.1", "pyqrcode==1.2.1",
"shortuuid==1.0.13", "shortuuid==1.0.13",

View file

@ -1,5 +1,4 @@
import hashlib import hashlib
from http import HTTPStatus
from json import JSONDecodeError from json import JSONDecodeError
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
@ -591,31 +590,26 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"minWithdrawable": 1000, "minWithdrawable": 1000,
"maxWithdrawable": 1_500_000, "maxWithdrawable": 1_500_000,
}, },
{ {"status": "OK"},
"status": "OK", {"success": True, "message": "Payment sent with NFC."},
},
{
"status": "OK",
},
), ),
# Error loading LNURL request # Error loading LNURL request
( (
"error_loading_lnurl", "error_loading_lnurl",
None, None,
{ {
"status": "ERROR", "detail": "Error loading callback request",
"reason": "Error loading callback request",
}, },
), ),
# LNURL response with error status # LNURL response with error status
( (
{ {
"status": "ERROR", "status": "ERROR",
"reason": "Invalid LNURL-withdraw response.",
}, },
None, None,
{ {
"status": "ERROR", "detail": "Invalid LNURL-withdraw response.",
"reason": "Invalid LNURL-withdraw response.",
}, },
), ),
# Invalid LNURL-withdraw pay request # Invalid LNURL-withdraw pay request
@ -629,8 +623,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
}, },
None, None,
{ {
"status": "ERROR", "detail": "Invalid LNURL-withdraw response.",
"reason": "Invalid LNURL-withdraw response.",
}, },
), ),
# Error loading callback request # Error loading callback request
@ -644,8 +637,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
}, },
"error_loading_callback", "error_loading_callback",
{ {
"status": "ERROR", "detail": "Error loading callback request",
"reason": "Error loading callback request",
}, },
), ),
# Callback response with error status # Callback response with error status
@ -662,8 +654,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"reason": "Callback failed", "reason": "Callback failed",
}, },
{ {
"status": "ERROR", "detail": "Callback failed",
"reason": "Callback failed",
}, },
), ),
# Unexpected exception during LNURL response JSON parsing # 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", "exception_in_lnurl_response_json",
None, None,
{ {
"status": "ERROR", "detail": "Invalid JSON response from https://example.com/lnurl",
"reason": "Invalid JSON response from https://example.com/lnurl",
}, },
), ),
], ],
@ -768,7 +758,6 @@ async def test_api_payment_pay_with_nfc(
json={"lnurl_w": lnurl}, json={"lnurl_w": lnurl},
) )
assert response.status_code == HTTPStatus.OK
assert response.json() == expected_response assert response.json() == expected_response

8
uv.lock generated
View file

@ -1363,7 +1363,7 @@ requires-dist = [
{ name = "itsdangerous", specifier = "==2.2.0" }, { name = "itsdangerous", specifier = "==2.2.0" },
{ name = "jinja2", specifier = "==3.1.6" }, { name = "jinja2", specifier = "==3.1.6" },
{ name = "jsonpath-ng", specifier = "==1.7.0" }, { 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 = "loguru", specifier = "==0.7.3" },
{ name = "nostr-sdk", specifier = "==0.42.1" }, { name = "nostr-sdk", specifier = "==0.42.1" },
{ name = "packaging", specifier = "==25.0" }, { name = "packaging", specifier = "==25.0" },
@ -1418,7 +1418,7 @@ dev = [
[[package]] [[package]]
name = "lnurl" name = "lnurl"
version = "0.8.2" version = "0.8.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "bech32" }, { name = "bech32" },
@ -1429,9 +1429,9 @@ dependencies = [
{ name = "pycryptodomex" }, { name = "pycryptodomex" },
{ name = "pydantic" }, { 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 = [ 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]] [[package]]