feat: update lnurl lib refactor lnurl endpoints (#3271)

This commit is contained in:
dni ⚡ 2025-07-17 16:03:44 +02:00 committed by GitHub
parent 98f732fb6f
commit a36ab2d408
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 417 additions and 723 deletions

View file

@ -11,6 +11,7 @@ from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension # this compat is needed for usermanager extension
from .views.generic import generic_router from .views.generic import generic_router
from .views.lnurl_api import lnurl_router
from .views.node_api import node_router, public_node_router, super_node_router from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router from .views.payment_api import payment_router
from .views.tinyurl_api import tinyurl_router from .views.tinyurl_api import tinyurl_router
@ -42,6 +43,7 @@ def init_core_routers(app: FastAPI):
app.include_router(users_router) app.include_router(users_router)
app.include_router(audit_router) app.include_router(audit_router)
app.include_router(fiat_router) app.include_router(fiat_router)
app.include_router(lnurl_router)
__all__ = ["core_app", "core_app_extra", "db"] __all__ = ["core_app", "core_app_extra", "db"]

View file

@ -1,5 +1,5 @@
from .audit import AuditEntry, AuditFilters from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
from .misc import ( from .misc import (
BalanceDelta, BalanceDelta,
Callback, Callback,
@ -63,8 +63,8 @@ __all__ = [
"ConversionData", "ConversionData",
"CoreAppExtra", "CoreAppExtra",
"CreateInvoice", "CreateInvoice",
"CreateLnurl", "CreateLnurlPayment",
"CreateLnurlAuth", "CreateLnurlWithdraw",
"CreatePayment", "CreatePayment",
"CreateUser", "CreateUser",
"CreateWallet", "CreateWallet",
@ -75,7 +75,6 @@ __all__ = [
"LoginUsernamePassword", "LoginUsernamePassword",
"LoginUsr", "LoginUsr",
"PayInvoice", "PayInvoice",
"PayLnurlWData",
"Payment", "Payment",
"PaymentCountField", "PaymentCountField",
"PaymentCountStat", "PaymentCountStat",

View file

@ -1,21 +1,16 @@
from typing import Optional from typing import Optional
from lnurl import Lnurl, LnurlPayResponse
from pydantic import BaseModel from pydantic import BaseModel
class CreateLnurl(BaseModel): class CreateLnurlPayment(BaseModel):
description_hash: str res: LnurlPayResponse
callback: str
amount: int amount: int
comment: Optional[str] = None comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None unit: Optional[str] = None
internal_memo: Optional[str] = None internal_memo: Optional[str] = None
class CreateLnurlAuth(BaseModel): class CreateLnurlWithdraw(BaseModel):
callback: str lnurl_w: Lnurl
class PayLnurlWData(BaseModel):
lnurl_w: str

View file

@ -5,6 +5,7 @@ from enum import Enum
from typing import Literal from typing import Literal
from fastapi import Query from fastapi import Query
from lnurl import LnurlWithdrawResponse
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel from lnbits.db import FilterModel
@ -262,7 +263,7 @@ class CreateInvoice(BaseModel):
extra: dict | None = None extra: dict | None = None
webhook: str | None = None webhook: str | None = None
bolt11: str | None = None bolt11: str | None = None
lnurl_callback: str | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
@validator("payment_hash") @validator("payment_hash")

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 perform_lnurlauth, redeem_lnurl_withdraw from .lnurl import fetch_lnurl_pay_request
from .notifications import enqueue_notification, send_payment_notification from .notifications import enqueue_notification, send_payment_notification
from .payments import ( from .payments import (
calculate_fiat_amounts, calculate_fiat_amounts,
@ -52,11 +52,10 @@ __all__ = [
"enqueue_notification", "enqueue_notification",
"fee_reserve", "fee_reserve",
"fee_reserve_total", "fee_reserve_total",
"fetch_lnurl_pay_request",
"get_balance_delta", "get_balance_delta",
"get_payments_daily_stats", "get_payments_daily_stats",
"pay_invoice", "pay_invoice",
"perform_lnurlauth",
"redeem_lnurl_withdraw",
"send_payment_notification", "send_payment_notification",
"service_fee", "service_fee",
"settle_hold_invoice", "settle_hold_invoice",

View file

@ -1,159 +1,29 @@
import asyncio from lnurl import LnurlPayActionResponse
import json from lnurl import execute_pay_request as lnurlp
from io import BytesIO
from typing import Optional
from urllib.parse import parse_qs, urlparse
import httpx from lnbits.core.models import CreateLnurlPayment
from fastapi import Depends
from loguru import logger
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
)
from lnbits.helpers import check_callback_url, url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from .payments import create_invoice
async def redeem_lnurl_withdraw( async def fetch_lnurl_pay_request(data: CreateLnurlPayment) -> LnurlPayActionResponse:
wallet_id: str, """
lnurl_request: str, Pay an LNURL payment request.
memo: Optional[str] = None,
extra: Optional[dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {} raises `LnurlResponseException` if pay request fails
"""
headers = {"User-Agent": settings.user_agent} if data.unit and data.unit != "sat":
async with httpx.AsyncClient(headers=headers) as client: # shift to float with 2 decimal places
lnurl = decode_lnurl(lnurl_request) amount = round(data.amount / 1000, 2)
check_callback_url(str(lnurl)) amount_msat = await fiat_amount_as_satoshis(amount, data.unit)
r = await client.get(str(lnurl)) amount_msat *= 1000
res = r.json() else:
amount_msat = data.amount
try: return await lnurlp(
_, payment_request = await create_invoice( data.res,
wallet_id=wallet_id, msat=str(amount_msat),
amount=int(res["maxWithdrawable"] / 1000), user_agent=settings.user_agent,
memo=memo or res["defaultDescription"] or "", timeout=5,
extra=extra,
conn=conn,
)
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception as exc:
logger.debug(exc)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(res["callback"])
await client.get(res["callback"], params=params)
except Exception as exc:
logger.debug(exc)
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
if not key.verifying_key:
raise ValueError("LNURLauth verifying_key does not exist")
check_callback_url(callback)
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
) )

View file

@ -1,13 +1,13 @@
import asyncio import asyncio
import json
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import httpx
from bolt11 import Bolt11, MilliSatoshi, Tags from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode from bolt11 import encode as bolt11_encode
from lnurl import LnurlErrorResponse, LnurlSuccessResponse
from lnurl import execute_withdraw as lnurl_withdraw
from loguru import logger from loguru import logger
from lnbits.core.crud.payments import get_daily_stats from lnbits.core.crud.payments import get_daily_stats
@ -172,8 +172,8 @@ async def create_fiat_invoice(
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment: async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
description_hash = b"" description_hash = None
unhashed_description = b"" unhashed_description = None
memo = data.memo or settings.lnbits_site_title memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description: if data.description_hash or data.unhashed_description:
if data.description_hash: if data.description_hash:
@ -207,28 +207,26 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
payment_hash=data.payment_hash, payment_hash=data.payment_hash,
) )
# lnurl_response is not saved in the database if data.lnurl_withdraw:
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try: try:
check_callback_url(data.lnurl_callback) check_callback_url(data.lnurl_withdraw.callback)
r = await client.get( res = await lnurl_withdraw(
data.lnurl_callback, data.lnurl_withdraw,
params={"pr": payment.bolt11}, payment.bolt11,
user_agent=settings.user_agent,
timeout=10, timeout=10,
) )
if r.is_error: if isinstance(res, LnurlErrorResponse):
payment.extra["lnurl_response"] = r.text payment.extra["lnurl_response"] = res.reason
else: payment.status = "failed"
resp = json.loads(r.text) elif isinstance(res, LnurlSuccessResponse):
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
else:
payment.extra["lnurl_response"] = True payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex: payment.status = "success"
logger.error(ex) except Exception as exc:
payment.extra["lnurl_response"] = False payment.extra["lnurl_response"] = str(exc)
payment.status = "failed"
# updating to payment here would run into a race condition
# with the payment listeners and they will overwrite each other
return payment return payment

View file

@ -1,36 +1,20 @@
import hashlib
import json
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO from io import BytesIO
from time import time from time import time
from typing import Any from typing import Any
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
import pyqrcode import pyqrcode
from fastapi import ( from fastapi import APIRouter, Depends
APIRouter,
Depends,
)
from fastapi.exceptions import HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from lnbits.core.models import ( from lnbits.core.models import (
BaseWallet, BaseWallet,
ConversionData, ConversionData,
CreateLnurlAuth,
CreateWallet, CreateWallet,
User, User,
Wallet, Wallet,
) )
from lnbits.decorators import ( from lnbits.decorators import check_user_exists
WalletTypeInfo,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
allowed_currencies, allowed_currencies,
@ -41,7 +25,7 @@ from lnbits.utils.exchange_rates import (
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from lnbits.wallets.base import StatusResponse from lnbits.wallets.base import StatusResponse
from ..services import create_user_account, perform_lnurlauth from ..services import create_user_account
api_router = APIRouter(tags=["Core"]) api_router = APIRouter(tags=["Core"])
@ -92,144 +76,6 @@ async def api_create_account(data: CreateWallet) -> Wallet:
return user.wallets[0] return user.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}")
async def api_lnurlscan( # noqa: C901
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
url = str(lnurl_decode(code))
domain = urlparse(url).netloc
except Exception as exc:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
name, domain = name_domain
url = (
("http://" if domain.endswith(".onion") else "https://")
+ domain
+ "/.well-known/lnurlp/"
+ name
)
# will proceed with these values
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
) from exc
# params is what will be returned to the client
params: dict = {"domain": domain}
if "tag=login" in url:
params.update(kind="auth")
params.update(callback=url) # with k1 already in it
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
if not lnurlauth_key.verifying_key:
raise ValueError("LNURL auth key not found for this domain.")
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
check_callback_url(url)
try:
r = await client.get(url, timeout=5)
r.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise HTTPException(HTTPStatus.NOT_FOUND, "Not found") from exc
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": "failed to get parameters",
},
) from exc
try:
data = json.loads(r.text)
except json.decoder.JSONDecodeError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
},
) from exc
try:
tag: str = data.get("tag")
params.update(**data)
if tag == "channelRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"domain": domain,
"kind": "channel",
"message": "unsupported",
},
)
elif tag == "withdrawRequest":
params.update(kind="withdraw")
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
if "balanceCheck" in data:
params.update(balanceCheck=data["balanceCheck"])
# format callback url and send to client
parsed_callback = parsed_callback._replace(
query=urlencode(qs, doseq=True)
)
params.update(callback=urlunparse(parsed_callback))
elif tag == "payRequest":
params.update(kind="pay")
params.update(fixed=data["minSendable"] == data["maxSendable"])
params.update(
description_hash=hashlib.sha256(
data["metadata"].encode()
).hexdigest()
)
metadata = json.loads(data["metadata"])
for [k, v] in metadata:
if k == "text/plain":
params.update(description=v)
if k in ("image/jpeg;base64", "image/png;base64"):
data_uri = f"data:{k},{v}"
params.update(image=data_uri)
if k in ("text/email", "text/identifier"):
params.update(targetUser=v)
params.update(commentAllowed=data.get("commentAllowed", 0))
except KeyError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"lnurl JSON response invalid: {exc}",
},
) from exc
return params
@api_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
):
err = await perform_lnurlauth(data.callback, wallet=wallet)
if err:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
)
return ""
@api_router.get( @api_router.get(
"/api/v1/rate/history", "/api/v1/rate/history",
dependencies=[Depends(check_user_exists)], dependencies=[Depends(check_user_exists)],

View file

@ -0,0 +1,143 @@
from http import HTTPStatus
from typing import Any
from fastapi import (
APIRouter,
Depends,
HTTPException,
)
from lnurl import (
LnurlResponseException,
LnurlSuccessResponse,
)
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,
LnurlErrorResponse,
LnurlPayResponse,
LnurlResponseModel,
LnurlWithdrawResponse,
)
from loguru import logger
from lnbits.core.models import CreateLnurlWithdraw, Payment
from lnbits.core.models.lnurl import CreateLnurlPayment
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from ..services import fetch_lnurl_pay_request, pay_invoice
lnurl_router = APIRouter(tags=["LNURL"])
@lnurl_router.get(
"/api/v1/lnurlscan/{code}",
dependencies=[Depends(require_invoice_key)],
response_model=LnurlPayResponse
| LnurlWithdrawResponse
| LnurlAuthResponse
| LnurlErrorResponse,
)
async def api_lnurlscan(code: str) -> LnurlResponseModel:
try:
res = await lnurl_handle(code, user_agent=settings.user_agent, timeout=5)
except LnurlResponseException as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
if isinstance(res, (LnurlPayResponse, LnurlWithdrawResponse, LnurlAuthResponse)):
check_callback_url(res.callback)
return res
@lnurl_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: LnurlAuthResponse, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> LnurlResponseModel:
check_callback_url(data.callback)
try:
res = await lnurlauth(
res=data,
seed=key_type.wallet.adminkey,
user_agent=settings.user_agent,
timeout=5,
)
return res
except LnurlResponseException as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
@lnurl_router.post("/api/v1/payments/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
try:
res = await fetch_lnurl_pay_request(data=data)
except LnurlResponseException as exc:
logger.warning(exc)
msg = f"Failed to connect to {data.res.callback}."
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) from exc
extra: dict[str, Any] = {}
if res.success_action:
extra["success_action"] = res.success_action.json()
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=str(res.pr),
description=data.res.metadata.text,
extra=extra,
)
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:
try:
res = await lnurl_handle(
lnurl_data.lnurl_w.callback_url, user_agent=settings.user_agent, timeout=10
)
except (LnurlResponseException, Exception) as exc:
logger.warning(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

@ -1,12 +1,7 @@
import json
import ssl
from hashlib import sha256 from hashlib import sha256
from http import HTTPStatus from http import HTTPStatus
from math import ceil
from typing import Optional from typing import Optional
from urllib.parse import urlparse
import httpx
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
@ -15,7 +10,7 @@ from fastapi import (
Query, Query,
) )
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from loguru import logger from lnurl import decode as lnurl_decode
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.crud.payments import ( from lnbits.core.crud.payments import (
@ -25,10 +20,8 @@ from lnbits.core.crud.payments import (
from lnbits.core.models import ( from lnbits.core.models import (
CancelInvoice, CancelInvoice,
CreateInvoice, CreateInvoice,
CreateLnurl,
DecodePayment, DecodePayment,
KeyType, KeyType,
PayLnurlWData,
Payment, Payment,
PaymentCountField, PaymentCountField,
PaymentCountStat, PaymentCountStat,
@ -48,13 +41,9 @@ from lnbits.decorators import (
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import ( from lnbits.helpers import (
check_callback_url,
filter_dict_keys, filter_dict_keys,
generate_filter_params_openapi, generate_filter_params_openapi,
) )
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from lnbits.wallets.base import InvoiceResponse from lnbits.wallets.base import InvoiceResponse
from ..crud import ( from ..crud import (
@ -284,92 +273,6 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
) )
def _validate_lnurl_response(
params: dict, domain: str, amount_msat: int
) -> bolt11.Invoice:
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != amount_msat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
)
return invoice
async def _fetch_lnurl_params(data: CreateLnurl, amount_msat: int, domain: str) -> dict:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
r: httpx.Response = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status()
except (httpx.HTTPError, ssl.SSLError) as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
) from exc
return json.loads(r.text)
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
domain = urlparse(data.callback).netloc
check_callback_url(data.callback)
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
params = await _fetch_lnurl_params(data, amount_msat, domain)
_validate_lnurl_response(params, domain, amount_msat)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
if data.internal_memo is not None:
if len(data.internal_memo) > 512:
raise ValueError("Internal memo must be 512 characters or less.")
extra["internal_memo"] = data.internal_memo
if data.description is None:
raise ValueError("Description is required")
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return payment
# TODO: refactor this route into a public and admin one # TODO: refactor this route into a public and admin one
@payment_router.get("/{payment_hash}") @payment_router.get("/{payment_hash}")
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)): async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
@ -428,62 +331,6 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
) )
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK)
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: PayLnurlWData,
) -> JSONResponse:
lnurl = lnurl_data.lnurl_w.lower()
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
url = lnurl.replace("lnurlw://", "https://")
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
check_callback_url(url)
lnurl_req = await client.get(url, timeout=10)
if lnurl_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading LNURL request"}
)
lnurl_res = lnurl_req.json()
if lnurl_res.get("status") == "ERROR":
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
if lnurl_res.get("tag") != "withdrawRequest":
return JSONResponse(
{"success": False, "detail": "Invalid LNURL-withdraw"}
)
callback_url = lnurl_res["callback"]
k1 = lnurl_res["k1"]
callback_req = await client.get(
callback_url,
params={"k1": k1, "pr": payment_request},
timeout=10,
)
if callback_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading callback request"}
)
callback_res = callback_req.json()
if callback_res.get("status") == "ERROR":
return JSONResponse(
{"success": False, "detail": callback_res["reason"]}
)
else:
return JSONResponse({"success": True, "detail": callback_res})
except Exception as e:
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
@payment_router.post("/settle") @payment_router.post("/settle")
async def api_payments_settle( async def api_payments_settle(
data: SettleInvoice, key_type: WalletTypeInfo = Depends(require_admin_key) data: SettleInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)

File diff suppressed because one or more lines are too long

View file

@ -19,7 +19,7 @@ window.LNbits = {
amount, amount,
memo, memo,
unit = 'sat', unit = 'sat',
lnurlCallback = null, lnurlWithdraw = null,
fiatProvider = null, fiatProvider = null,
internalMemo = null, internalMemo = null,
payment_hash = null payment_hash = null
@ -29,7 +29,7 @@ window.LNbits = {
amount: amount, amount: amount,
memo: memo, memo: memo,
unit: unit, unit: unit,
lnurl_callback: lnurlCallback, lnurl_withdraw: lnurlWithdraw,
fiat_provider: fiatProvider, fiat_provider: fiatProvider,
payment_hash: payment_hash payment_hash: payment_hash
} }
@ -52,36 +52,6 @@ window.LNbits = {
} }
return this.request('post', '/api/v1/payments', wallet.adminkey, data) return this.request('post', '/api/v1/payments', wallet.adminkey, data)
}, },
payLnurl(
wallet,
callback,
description_hash,
amount,
description = '',
comment = '',
unit = '',
internalMemo = null
) {
const data = {
callback,
description_hash,
amount,
comment,
description,
unit
}
if (internalMemo) {
data.internal_memo = String(internalMemo)
}
return this.request(
'post',
'/api/v1/payments/lnurl',
wallet.adminkey,
data
)
},
cancelInvoice(wallet, paymentHash) { cancelInvoice(wallet, paymentHash) {
return this.request('post', '/api/v1/payments/cancel', wallet.adminkey, { return this.request('post', '/api/v1/payments/cancel', wallet.adminkey, {
payment_hash: paymentHash payment_hash: paymentHash
@ -92,11 +62,6 @@ window.LNbits = {
preimage: preimage preimage: preimage
}) })
}, },
authLnurl(wallet, callback) {
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
callback
})
},
createAccount(name) { createAccount(name) {
return this.request('post', '/api/v1/account', null, { return this.request('post', '/api/v1/account', null, {
name: name name: name

View file

@ -258,7 +258,7 @@ window.WalletPageLogic = {
this.receive.data.amount, this.receive.data.amount,
this.receive.data.memo, this.receive.data.memo,
this.receive.unit, this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback, this.receive.lnurlWithdraw,
this.receive.fiatProvider, this.receive.fiatProvider,
this.receive.data.internalMemo, this.receive.data.internalMemo,
this.receive.data.payment_hash this.receive.data.payment_hash
@ -276,24 +276,24 @@ window.WalletPageLogic = {
} }
// TODO: lnurl_callback and lnurl_response // TODO: lnurl_callback and lnurl_response
// WITHDRAW // WITHDRAW
if (response.data.lnurl_response !== null) { if (response.data.extra.lnurl_response !== null) {
if (response.data.lnurl_response === false) { if (response.data.extra.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect` response.data.extra.lnurl_response = `Unable to connect`
} }
if (typeof response.data.lnurl_response === 'string') { if (typeof response.data.extra.lnurl_response === 'string') {
// failure // failure
Quasar.Notify.create({ Quasar.Notify.create({
timeout: 5000, timeout: 5000,
type: 'warning', type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`, message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
caption: response.data.lnurl_response caption: response.data.extra.lnurl_response
}) })
return return
} else if (response.data.lnurl_response === true) { } else if (response.data.extra.lnurl_response === true) {
// success // success
Quasar.Notify.create({ Quasar.Notify.create({
timeout: 5000, timeout: 3000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`, message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true spinner: true
}) })
@ -361,14 +361,15 @@ window.WalletPageLogic = {
return return
} }
if (data.kind === 'pay') { if (data.tag === 'payRequest') {
this.parse.lnurlpay = Object.freeze(data) this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000 this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') { } else if (data.tag === 'login') {
this.parse.lnurlauth = Object.freeze(data) this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') { } else if (data.tag === 'withdrawRequest') {
this.parse.show = false this.parse.show = false
this.receive.show = true this.receive.show = true
this.receive.lnurlWithdraw = Object.freeze(data)
this.receive.status = 'pending' this.receive.status = 'pending'
this.receive.paymentReq = null this.receive.paymentReq = null
this.receive.paymentHash = null this.receive.paymentHash = null
@ -378,8 +379,9 @@ window.WalletPageLogic = {
data.minWithdrawable / 1000, data.minWithdrawable / 1000,
data.maxWithdrawable / 1000 data.maxWithdrawable / 1000
] ]
const domain = data.callback.split('/')[2]
this.receive.lnurl = { this.receive.lnurl = {
domain: data.domain, domain: domain,
callback: data.callback, callback: data.callback,
fixed: data.fixed fixed: data.fixed
} }
@ -515,43 +517,23 @@ window.WalletPageLogic = {
}) })
}, },
payLnurl() { payLnurl() {
const dismissPaymentMsg = Quasar.Notify.create({
timeout: 0,
message: 'Processing payment...'
})
LNbits.api LNbits.api
.payLnurl( .request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
this.g.wallet, res: this.parse.lnurlpay,
this.parse.lnurlpay.callback, unit: this.parse.data.unit,
this.parse.lnurlpay.description_hash, amount: this.parse.data.amount * 1000,
this.parse.data.amount * 1000, comment: this.parse.data.comment,
this.parse.lnurlpay.description.slice(0, 120), internalMemo: this.parse.data.internalMemo
this.parse.data.comment, })
this.parse.data.unit,
this.parse.data.internalMemo
)
.then(response => { .then(response => {
this.parse.show = false this.parse.show = false
if (response.data.extra.success_action) {
clearInterval(this.parse.paymentChecker) const action = JSON.parse(response.data.extra.success_action)
setTimeout(() => { switch (action.tag) {
clearInterval(this.parse.paymentChecker)
}, 40000)
this.parse.paymentChecker = setInterval(() => {
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(res => {
if (res.data.paid) {
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
// show lnurlpay success action
const extra = response.data.extra
if (extra.success_action) {
switch (extra.success_action.tag) {
case 'url': case 'url':
Quasar.Notify.create({ Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`, message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`,
caption: extra.success_action.description, caption: action.description,
html: true, html: true,
type: 'positive', type: 'positive',
timeout: 0, timeout: 0,
@ -560,22 +542,14 @@ window.WalletPageLogic = {
break break
case 'message': case 'message':
Quasar.Notify.create({ Quasar.Notify.create({
message: extra.success_action.message, message: action.message,
type: 'positive', type: 'positive',
timeout: 0, timeout: 0,
closeBtn: true closeBtn: true
}) })
break break
case 'aes': case 'aes':
LNbits.api decryptLnurlPayAES(action, response.data.preimage)
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
extra.success_action,
payment.preimage
)
)
.then(value => {
Quasar.Notify.create({ Quasar.Notify.create({
message: value, message: value,
caption: extra.success_action.description, caption: extra.success_action.description,
@ -584,17 +558,8 @@ window.WalletPageLogic = {
timeout: 0, timeout: 0,
closeBtn: true closeBtn: true
}) })
})
break
} }
} }
}
})
}, 2000)
})
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
}) })
}, },
authLnurl() { authLnurl() {
@ -602,9 +567,13 @@ window.WalletPageLogic = {
timeout: 10, timeout: 10,
message: 'Performing authentication...' message: 'Performing authentication...'
}) })
LNbits.api LNbits.api
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback) .request(
'post',
'/api/v1/lnurlauth',
wallet.adminkey,
this.parse.lnurlauth
)
.then(_ => { .then(_ => {
dismissAuthMsg() dismissAuthMsg()
Quasar.Notify.create({ Quasar.Notify.create({
@ -615,10 +584,9 @@ window.WalletPageLogic = {
this.parse.show = false this.parse.show = false
}) })
.catch(err => { .catch(err => {
dismissAuthMsg()
if (err.response.data.reason) { if (err.response.data.reason) {
Quasar.Notify.create({ Quasar.Notify.create({
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`, message: `Authentication failed. ${this.parse.lnurlauth.callback} says:`,
caption: err.response.data.reason, caption: err.response.data.reason,
type: 'warning', type: 'warning',
timeout: 5000 timeout: 5000

130
poetry.lock generated
View file

@ -455,6 +455,21 @@ files = [
{file = "bech32-1.2.0.tar.gz", hash = "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899"}, {file = "bech32-1.2.0.tar.gz", hash = "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899"},
] ]
[[package]]
name = "bip32"
version = "4.0"
description = "Minimalistic implementation of the BIP32 key derivation scheme"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "bip32-4.0-py3-none-any.whl", hash = "sha256:9728b38336129c00e1f870bbb3e328c9632d51c1bddeef4011fd3115cb3aeff9"},
{file = "bip32-4.0.tar.gz", hash = "sha256:8035588f252f569bb414bc60df151ae431fc1c6789a19488a32890532ef3a2fc"},
]
[package.dependencies]
coincurve = ">=15.0,<21"
[[package]] [[package]]
name = "bitarray" name = "bitarray"
version = "3.4.3" version = "3.4.3"
@ -2118,21 +2133,23 @@ valkey = ["valkey (>=6)"]
[[package]] [[package]]
name = "lnurl" name = "lnurl"
version = "0.5.3" version = "0.6.8"
description = "LNURL implementation for Python." description = "LNURL implementation for Python."
optional = false optional = false
python-versions = "<4.0,>=3.9" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "lnurl-0.5.3-py3-none-any.whl", hash = "sha256:feaf6c60b0b7f104894ef3accbd30d23d52e038c2797c58432baea7f4a8aa952"}, {file = "lnurl-0.6.8-py3-none-any.whl", hash = "sha256:4fff53efcdd401cf4169676bd1ab85e9e241a762a9a5407ee11e6a6e120e8279"},
{file = "lnurl-0.5.3.tar.gz", hash = "sha256:60154bcfdbb98fb143eeca970a16d73a582f28e057a826b5f222259411c497fe"}, {file = "lnurl-0.6.8.tar.gz", hash = "sha256:de64a47179980a4b52cd6b89ad377cda14502f1998f53724490683f6f5c4ed90"},
] ]
[package.dependencies] [package.dependencies]
bech32 = ">=1.2.0,<2.0.0" bech32 = "*"
bolt11 = ">=2.0.5,<3.0.0" bip32 = ">=4.0,<5.0"
ecdsa = ">=0.19.0,<0.20.0" bolt11 = "*"
httpx = ">=0.27.0,<0.28.0" ecdsa = "*"
httpx = "*"
pycryptodomex = ">=3.21.0,<4.0.0"
pydantic = ">=1,<2" pydantic = ">=1,<2"
[[package]] [[package]]
@ -2981,55 +2998,62 @@ files = [
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "1.10.18" version = "1.10.22"
description = "Data validation and settings management using python type hints" description = "Data validation and settings management using python type hints"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main", "dev"] groups = ["main", "dev"]
files = [ files = [
{file = "pydantic-1.10.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e405ffcc1254d76bb0e760db101ee8916b620893e6edfbfee563b3c6f7a67c02"}, {file = "pydantic-1.10.22-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:57889565ccc1e5b7b73343329bbe6198ebc472e3ee874af2fa1865cfe7048228"},
{file = "pydantic-1.10.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e306e280ebebc65040034bff1a0a81fd86b2f4f05daac0131f29541cafd80b80"}, {file = "pydantic-1.10.22-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:90729e22426de79bc6a3526b4c45ec4400caf0d4f10d7181ba7f12c01bb3897d"},
{file = "pydantic-1.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11d9d9b87b50338b1b7de4ebf34fd29fdb0d219dc07ade29effc74d3d2609c62"}, {file = "pydantic-1.10.22-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8684d347f351554ec94fdcb507983d3116dc4577fb8799fed63c65869a2d10"},
{file = "pydantic-1.10.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b661ce52c7b5e5f600c0c3c5839e71918346af2ef20062705ae76b5c16914cab"}, {file = "pydantic-1.10.22-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8dad498ceff2d9ef1d2e2bc6608f5b59b8e1ba2031759b22dfb8c16608e1802"},
{file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c20f682defc9ef81cd7eaa485879ab29a86a0ba58acf669a78ed868e72bb89e0"}, {file = "pydantic-1.10.22-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fac529cc654d4575cf8de191cce354b12ba705f528a0a5c654de6d01f76cd818"},
{file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c5ae6b7c8483b1e0bf59e5f1843e4fd8fd405e11df7de217ee65b98eb5462861"}, {file = "pydantic-1.10.22-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4148232aded8dd1dd13cf910a01b32a763c34bd79a0ab4d1ee66164fcb0b7b9d"},
{file = "pydantic-1.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:74fe19dda960b193b0eb82c1f4d2c8e5e26918d9cda858cbf3f41dd28549cb70"}, {file = "pydantic-1.10.22-cp310-cp310-win_amd64.whl", hash = "sha256:ece68105d9e436db45d8650dc375c760cc85a6793ae019c08769052902dca7db"},
{file = "pydantic-1.10.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72fa46abace0a7743cc697dbb830a41ee84c9db8456e8d77a46d79b537efd7ec"}, {file = "pydantic-1.10.22-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e530a8da353f791ad89e701c35787418605d35085f4bdda51b416946070e938"},
{file = "pydantic-1.10.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef0fe7ad7cbdb5f372463d42e6ed4ca9c443a52ce544472d8842a0576d830da5"}, {file = "pydantic-1.10.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:654322b85642e9439d7de4c83cb4084ddd513df7ff8706005dada43b34544946"},
{file = "pydantic-1.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00e63104346145389b8e8f500bc6a241e729feaf0559b88b8aa513dd2065481"}, {file = "pydantic-1.10.22-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8bece75bd1b9fc1c32b57a32831517943b1159ba18b4ba32c0d431d76a120ae"},
{file = "pydantic-1.10.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae6fa2008e1443c46b7b3a5eb03800121868d5ab6bc7cda20b5df3e133cde8b3"}, {file = "pydantic-1.10.22-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eccb58767f13c6963dcf96d02cb8723ebb98b16692030803ac075d2439c07b0f"},
{file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9f463abafdc92635da4b38807f5b9972276be7c8c5121989768549fceb8d2588"}, {file = "pydantic-1.10.22-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7778e6200ff8ed5f7052c1516617423d22517ad36cc7a3aedd51428168e3e5e8"},
{file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3445426da503c7e40baccefb2b2989a0c5ce6b163679dd75f55493b460f05a8f"}, {file = "pydantic-1.10.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffe02767d27c39af9ca7dc7cd479c00dda6346bb62ffc89e306f665108317a2"},
{file = "pydantic-1.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:467a14ee2183bc9c902579bb2f04c3d3dac00eff52e252850509a562255b2a33"}, {file = "pydantic-1.10.22-cp311-cp311-win_amd64.whl", hash = "sha256:23bc19c55427091b8e589bc08f635ab90005f2dc99518f1233386f46462c550a"},
{file = "pydantic-1.10.18-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:efbc8a7f9cb5fe26122acba1852d8dcd1e125e723727c59dcd244da7bdaa54f2"}, {file = "pydantic-1.10.22-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:92d0f97828a075a71d9efc65cf75db5f149b4d79a38c89648a63d2932894d8c9"},
{file = "pydantic-1.10.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24a4a159d0f7a8e26bf6463b0d3d60871d6a52eac5bb6a07a7df85c806f4c048"}, {file = "pydantic-1.10.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6af5a2811b6b95b58b829aeac5996d465a5f0c7ed84bd871d603cf8646edf6ff"},
{file = "pydantic-1.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b74be007703547dc52e3c37344d130a7bfacca7df112a9e5ceeb840a9ce195c7"}, {file = "pydantic-1.10.22-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cf06d8d40993e79af0ab2102ef5da77b9ddba51248e4cb27f9f3f591fbb096e"},
{file = "pydantic-1.10.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcb20d4cb355195c75000a49bb4a31d75e4295200df620f454bbc6bdf60ca890"}, {file = "pydantic-1.10.22-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:184b7865b171a6057ad97f4a17fbac81cec29bd103e996e7add3d16b0d95f609"},
{file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46f379b8cb8a3585e3f61bf9ae7d606c70d133943f339d38b76e041ec234953f"}, {file = "pydantic-1.10.22-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:923ad861677ab09d89be35d36111156063a7ebb44322cdb7b49266e1adaba4bb"},
{file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cbfbca662ed3729204090c4d09ee4beeecc1a7ecba5a159a94b5a4eb24e3759a"}, {file = "pydantic-1.10.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:82d9a3da1686443fb854c8d2ab9a473251f8f4cdd11b125522efb4d7c646e7bc"},
{file = "pydantic-1.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:c6d0a9f9eccaf7f438671a64acf654ef0d045466e63f9f68a579e2383b63f357"}, {file = "pydantic-1.10.22-cp312-cp312-win_amd64.whl", hash = "sha256:1612604929af4c602694a7f3338b18039d402eb5ddfbf0db44f1ebfaf07f93e7"},
{file = "pydantic-1.10.18-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d5492dbf953d7d849751917e3b2433fb26010d977aa7a0765c37425a4026ff1"}, {file = "pydantic-1.10.22-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b259dc89c9abcd24bf42f31951fb46c62e904ccf4316393f317abeeecda39978"},
{file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe734914977eed33033b70bfc097e1baaffb589517863955430bf2e0846ac30f"}, {file = "pydantic-1.10.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9238aa0964d80c0908d2f385e981add58faead4412ca80ef0fa352094c24e46d"},
{file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15fdbe568beaca9aacfccd5ceadfb5f1a235087a127e8af5e48df9d8a45ae85c"}, {file = "pydantic-1.10.22-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8029f05b04080e3f1a550575a1bca747c0ea4be48e2d551473d47fd768fc1b"},
{file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c3e742f62198c9eb9201781fbebe64533a3bbf6a76a91b8d438d62b813079dbc"}, {file = "pydantic-1.10.22-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c06918894f119e0431a36c9393bc7cceeb34d1feeb66670ef9b9ca48c073937"},
{file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19a3bd00b9dafc2cd7250d94d5b578edf7a0bd7daf102617153ff9a8fa37871c"}, {file = "pydantic-1.10.22-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e205311649622ee8fc1ec9089bd2076823797f5cd2c1e3182dc0e12aab835b35"},
{file = "pydantic-1.10.18-cp37-cp37m-win_amd64.whl", hash = "sha256:2ce3fcf75b2bae99aa31bd4968de0474ebe8c8258a0110903478bd83dfee4e3b"}, {file = "pydantic-1.10.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:815f0a73d5688d6dd0796a7edb9eca7071bfef961a7b33f91e618822ae7345b7"},
{file = "pydantic-1.10.18-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:335a32d72c51a313b33fa3a9b0fe283503272ef6467910338e123f90925f0f03"}, {file = "pydantic-1.10.22-cp313-cp313-win_amd64.whl", hash = "sha256:9dfce71d42a5cde10e78a469e3d986f656afc245ab1b97c7106036f088dd91f8"},
{file = "pydantic-1.10.18-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:34a3613c7edb8c6fa578e58e9abe3c0f5e7430e0fc34a65a415a1683b9c32d9a"}, {file = "pydantic-1.10.22-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3ecaf8177b06aac5d1f442db1288e3b46d9f05f34fd17fdca3ad34105328b61a"},
{file = "pydantic-1.10.18-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9ee4e6ca1d9616797fa2e9c0bfb8815912c7d67aca96f77428e316741082a1b"}, {file = "pydantic-1.10.22-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb36c2de9ea74bd7f66b5481dea8032d399affd1cbfbb9bb7ce539437f1fce62"},
{file = "pydantic-1.10.18-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23e8ec1ce4e57b4f441fc91e3c12adba023fedd06868445a5b5f1d48f0ab3682"}, {file = "pydantic-1.10.22-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6b8d14a256be3b8fff9286d76c532f1a7573fbba5f189305b22471c6679854d"},
{file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:44ae8a3e35a54d2e8fa88ed65e1b08967a9ef8c320819a969bfa09ce5528fafe"}, {file = "pydantic-1.10.22-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:1c33269e815db4324e71577174c29c7aa30d1bba51340ce6be976f6f3053a4c6"},
{file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5389eb3b48a72da28c6e061a247ab224381435256eb541e175798483368fdd3"}, {file = "pydantic-1.10.22-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:8661b3ab2735b2a9ccca2634738534a795f4a10bae3ab28ec0a10c96baa20182"},
{file = "pydantic-1.10.18-cp38-cp38-win_amd64.whl", hash = "sha256:069b9c9fc645474d5ea3653788b544a9e0ccd3dca3ad8c900c4c6eac844b4620"}, {file = "pydantic-1.10.22-cp37-cp37m-win_amd64.whl", hash = "sha256:22bdd5fe70d4549995981c55b970f59de5c502d5656b2abdfcd0a25be6f3763e"},
{file = "pydantic-1.10.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80b982d42515632eb51f60fa1d217dfe0729f008e81a82d1544cc392e0a50ddf"}, {file = "pydantic-1.10.22-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e3f33d1358aa4bc2795208cc29ff3118aeaad0ea36f0946788cf7cadeccc166b"},
{file = "pydantic-1.10.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aad8771ec8dbf9139b01b56f66386537c6fe4e76c8f7a47c10261b69ad25c2c9"}, {file = "pydantic-1.10.22-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:813f079f9cd136cac621f3f9128a4406eb8abd2ad9fdf916a0731d91c6590017"},
{file = "pydantic-1.10.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941a2eb0a1509bd7f31e355912eb33b698eb0051730b2eaf9e70e2e1589cae1d"}, {file = "pydantic-1.10.22-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab618ab8dca6eac7f0755db25f6aba3c22c40e3463f85a1c08dc93092d917704"},
{file = "pydantic-1.10.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65f7361a09b07915a98efd17fdec23103307a54db2000bb92095457ca758d485"}, {file = "pydantic-1.10.22-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d128e1aaa38db88caca920d5822c98fc06516a09a58b6d3d60fa5ea9099b32cc"},
{file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6951f3f47cb5ca4da536ab161ac0163cab31417d20c54c6de5ddcab8bc813c3f"}, {file = "pydantic-1.10.22-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:cc97bbc25def7025e55fc9016080773167cda2aad7294e06a37dda04c7d69ece"},
{file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a4c5eec138a9b52c67f664c7d51d4c7234c5ad65dd8aacd919fb47445a62c86"}, {file = "pydantic-1.10.22-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dda5d7157d543b1fa565038cae6e952549d0f90071c839b3740fb77c820fab8"},
{file = "pydantic-1.10.18-cp39-cp39-win_amd64.whl", hash = "sha256:49e26c51ca854286bffc22b69787a8d4063a62bf7d83dc21d44d2ff426108518"}, {file = "pydantic-1.10.22-cp38-cp38-win_amd64.whl", hash = "sha256:a093fe44fe518cb445d23119511a71f756f8503139d02fcdd1173f7b76c95ffe"},
{file = "pydantic-1.10.18-py3-none-any.whl", hash = "sha256:06a189b81ffc52746ec9c8c007f16e5167c8b0a696e1a726369327e3db7b2a82"}, {file = "pydantic-1.10.22-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ec54c89b2568b258bb30d7348ac4d82bec1b58b377fb56a00441e2ac66b24587"},
{file = "pydantic-1.10.18.tar.gz", hash = "sha256:baebdff1907d1d96a139c25136a9bb7d17e118f133a76a2ef3b845e831e3403a"}, {file = "pydantic-1.10.22-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d8f1d1a1532e4f3bcab4e34e8d2197a7def4b67072acd26cfa60e92d75803a48"},
{file = "pydantic-1.10.22-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ad83ca35508c27eae1005b6b61f369f78aae6d27ead2135ec156a2599910121"},
{file = "pydantic-1.10.22-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53cdb44b78c420f570ff16b071ea8cd5a477635c6b0efc343c8a91e3029bbf1a"},
{file = "pydantic-1.10.22-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:16d0a5ae9d98264186ce31acdd7686ec05fd331fab9d68ed777d5cb2d1514e5e"},
{file = "pydantic-1.10.22-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8aee040e25843f036192b1a1af62117504a209a043aa8db12e190bb86ad7e611"},
{file = "pydantic-1.10.22-cp39-cp39-win_amd64.whl", hash = "sha256:7f691eec68dbbfca497d3c11b92a3e5987393174cbedf03ec7a4184c35c2def6"},
{file = "pydantic-1.10.22-py3-none-any.whl", hash = "sha256:343037d608bcbd34df937ac259708bfc83664dadf88afe8516c4f282d7d471a9"},
{file = "pydantic-1.10.22.tar.gz", hash = "sha256:ee1006cebd43a8e7158fb7190bb8f4e2da9649719bff65d0c287282ec38dec6d"},
] ]
[package.dependencies] [package.dependencies]
@ -4549,4 +4573,4 @@ migration = ["psycopg2-binary"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = "~3.12 | ~3.11 | ~3.10" python-versions = "~3.12 | ~3.11 | ~3.10"
content-hash = "9f42f1abe4036957a574f61f84566522334414e1d43e5b993cc989470945aa32" content-hash = "c1714a4df0e8f7d8702fffe9c57b21fc39effb194631741b4718466c18b73d8f"

View file

@ -20,8 +20,8 @@ fastapi = "0.116.1"
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.5.3" lnurl = "0.6.8"
pydantic = "1.10.18" pydantic = "1.10.22"
pyqrcode = "1.2.1" pyqrcode = "1.2.1"
shortuuid = "1.0.13" shortuuid = "1.0.13"
sse-starlette = "2.3.6" sse-starlette = "2.3.6"

View file

@ -1,5 +1,6 @@
import hashlib import hashlib
from http import HTTPStatus from http import HTTPStatus
from json import JSONDecodeError
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest import pytest
@ -587,13 +588,14 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"tag": "withdrawRequest", "tag": "withdrawRequest",
"callback": "https://example.com/callback", "callback": "https://example.com/callback",
"k1": "randomk1value", "k1": "randomk1value",
"minWithdrawable": 1000,
"maxWithdrawable": 1_500_000,
}, },
{ {
"status": "OK", "status": "OK",
}, },
{ {
"success": True, "status": "OK",
"detail": {"status": "OK"},
}, },
), ),
# Error loading LNURL request # Error loading LNURL request
@ -601,33 +603,35 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"error_loading_lnurl", "error_loading_lnurl",
None, None,
{ {
"success": False, "status": "ERROR",
"detail": "Error loading LNURL request", "reason": "Error loading callback request",
}, },
), ),
# LNURL response with error status # LNURL response with error status
( (
{ {
"status": "ERROR", "status": "ERROR",
"reason": "LNURL request failed",
}, },
None, None,
{ {
"success": False, "status": "ERROR",
"detail": "LNURL request failed", "reason": "Invalid LNURL-withdraw response.",
}, },
), ),
# Invalid LNURL-withdraw # Invalid LNURL-withdraw pay request
( (
{ {
"tag": "payRequest", "tag": "payRequest",
"callback": "https://example.com/callback", "callback": "https://example.com/callback",
"k1": "randomk1value", "k1": "randomk1value",
"minSendable": 1000,
"maxSendable": 1_500_000,
"metadata": '[["text/plain", "Payment to yo"]]',
}, },
None, None,
{ {
"success": False, "status": "ERROR",
"detail": "Invalid LNURL-withdraw", "reason": "Invalid LNURL-withdraw response.",
}, },
), ),
# Error loading callback request # Error loading callback request
@ -636,11 +640,13 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"tag": "withdrawRequest", "tag": "withdrawRequest",
"callback": "https://example.com/callback", "callback": "https://example.com/callback",
"k1": "randomk1value", "k1": "randomk1value",
"minWithdrawable": 1000,
"maxWithdrawable": 1_500_000,
}, },
"error_loading_callback", "error_loading_callback",
{ {
"success": False, "status": "ERROR",
"detail": "Error loading callback request", "reason": "Error loading callback request",
}, },
), ),
# Callback response with error status # Callback response with error status
@ -649,14 +655,16 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"tag": "withdrawRequest", "tag": "withdrawRequest",
"callback": "https://example.com/callback", "callback": "https://example.com/callback",
"k1": "randomk1value", "k1": "randomk1value",
"minWithdrawable": 1000,
"maxWithdrawable": 1_500_000,
}, },
{ {
"status": "ERROR", "status": "ERROR",
"reason": "Callback failed", "reason": "Callback failed",
}, },
{ {
"success": False, "status": "ERROR",
"detail": "Callback failed", "reason": "Callback failed",
}, },
), ),
# Unexpected exception during LNURL response JSON parsing # Unexpected exception during LNURL response JSON parsing
@ -664,8 +672,8 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
"exception_in_lnurl_response_json", "exception_in_lnurl_response_json",
None, None,
{ {
"success": False, "status": "ERROR",
"detail": "Unexpected error: Simulated exception", "reason": "Invalid JSON response from https://example.com/lnurl",
}, },
), ),
], ],
@ -677,25 +685,35 @@ async def test_api_payment_pay_with_nfc(
callback_response_data, callback_response_data,
expected_response, expected_response,
): ):
payment_request = "lnbc1..." payment_request = (
"lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdq"
"svfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfu"
"vqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0"
"rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs"
)
lnurl = "lnurlw://example.com/lnurl" lnurl = "lnurlw://example.com/lnurl"
lnurl_data = {"lnurl_w": lnurl}
# Create a mock for httpx.AsyncClient # Create a mock for httpx.AsyncClient
mock_async_client = AsyncMock() mock_async_client = AsyncMock()
mock_async_client.__aenter__.return_value = mock_async_client mock_async_client.__aenter__.return_value = mock_async_client
# Mock the get method # Mock the get method
async def mock_get(url, *args, **kwargs): async def mock_get(url, *_, **__):
if url == "https://example.com/lnurl": if url == "https://example.com/lnurl":
if lnurl_response_data == "error_loading_lnurl": if lnurl_response_data == "error_loading_lnurl":
response = Mock() response = Mock()
response.is_error = True response.is_error = True
response.status_code = 500
response.raise_for_status.side_effect = Exception(
"Error loading callback request"
)
return response return response
elif lnurl_response_data == "exception_in_lnurl_response_json": elif lnurl_response_data == "exception_in_lnurl_response_json":
response = Mock() response = Mock()
response.is_error = False response.is_error = False
response.json.side_effect = Exception("Simulated exception") response.json.side_effect = JSONDecodeError(
doc="Simulated exception", pos=0, msg="JSONDecodeError"
)
return response return response
elif isinstance(lnurl_response_data, dict): elif isinstance(lnurl_response_data, dict):
response = Mock() response = Mock()
@ -706,11 +724,19 @@ async def test_api_payment_pay_with_nfc(
# Handle unexpected data # Handle unexpected data
response = Mock() response = Mock()
response.is_error = True response.is_error = True
response.status_code = 500
response.raise_for_status.side_effect = Exception(
"Error loading callback request"
)
return response return response
elif url == "https://example.com/callback": elif url == "https://example.com/callback":
if callback_response_data == "error_loading_callback": if callback_response_data == "error_loading_callback":
response = Mock() response = Mock()
response.is_error = True response.is_error = True
response.status_code = 500
response.raise_for_status.side_effect = Exception(
"Error loading callback request"
)
return response return response
elif isinstance(callback_response_data, dict): elif isinstance(callback_response_data, dict):
response = Mock() response = Mock()
@ -721,10 +747,16 @@ async def test_api_payment_pay_with_nfc(
# Handle cases where callback is not called # Handle cases where callback is not called
response = Mock() response = Mock()
response.is_error = True response.is_error = True
response.raise_for_status.side_effect = Exception(
"Error loading callback request"
)
return response return response
else: else:
response = Mock() response = Mock()
response.is_error = True response.is_error = True
response.raise_for_status.side_effect = Exception(
"Error loading callback request"
)
return response return response
mock_async_client.get.side_effect = mock_get mock_async_client.get.side_effect = mock_get
@ -734,7 +766,7 @@ async def test_api_payment_pay_with_nfc(
response = await client.post( response = await client.post(
f"/api/v1/payments/{payment_request}/pay-with-nfc", f"/api/v1/payments/{payment_request}/pay-with-nfc",
json=lnurl_data, json={"lnurl_w": lnurl},
) )
assert response.status_code == HTTPStatus.OK assert response.status_code == HTTPStatus.OK
@ -743,27 +775,32 @@ async def test_api_payment_pay_with_nfc(
@pytest.mark.anyio @pytest.mark.anyio
async def test_api_payments_pay_lnurl(client, adminkey_headers_from): async def test_api_payments_pay_lnurl(client, adminkey_headers_from):
valid_lnurl_data = { lnurl_data = {
"description_hash": "randomhash", "res": {
"callback": "https://xxxxxxx.lnbits.com", "callback": "https://xxxxxxx.lnbits.com",
"minSendable": 1000,
"maxSendable": 1_500_000,
"metadata": '[["text/plain", "Payment to yo"]]',
},
"amount": 1000, "amount": 1000,
"unit": "sat", "unit": "sat",
"comment": "test comment", "comment": "test comment",
"description": "test description", "description": "test description",
} }
invalid_lnurl_data = {**valid_lnurl_data, "callback": "invalid_url"}
# Test with valid callback URL # Test with valid callback URL
response = await client.post( response = await client.post(
"/api/v1/payments/lnurl", json=valid_lnurl_data, headers=adminkey_headers_from "/api/v1/payments/lnurl", json=lnurl_data, headers=adminkey_headers_from
) )
assert response.status_code == 400 assert response.status_code == 400
assert response.json()["detail"] == "Failed to connect to xxxxxxx.lnbits.com." assert (
response.json()["detail"] == "Failed to connect to https://xxxxxxx.lnbits.com."
)
# Test with invalid callback URL # Test with invalid callback URL
lnurl_data["res"]["callback"] = "invalid-url.lnbits.com"
response = await client.post( response = await client.post(
"/api/v1/payments/lnurl", json=invalid_lnurl_data, headers=adminkey_headers_from "/api/v1/payments/lnurl", json=lnurl_data, headers=adminkey_headers_from
) )
assert response.status_code == 400 assert response.status_code == 400
assert "Callback not allowed." in response.json()["detail"] assert "value_error.url.scheme" in response.json()["detail"]