fix: pay with nfc endpoint + add perform_withdraw (#3352)
This commit is contained in:
parent
c0b33560bb
commit
672a5b3a4d
8 changed files with 74 additions and 71 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
8
poetry.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
8
uv.lock
generated
|
|
@ -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]]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue