From f64f64f6bb8289337fc72b48df231918606411e8 Mon Sep 17 00:00:00 2001 From: Arc Date: Fri, 10 Jul 2026 15:20:15 +0100 Subject: [PATCH] feat: adds lnaddress support to payments in host --- lnbits/core/wasm_ext/api/host.py | 40 +++++++-- lnbits/core/wasm_ext/wasm/invoke.py | 1 + tests/unit/test_wasm_extension_host_wallet.py | 90 +++++++++++++++++++ 3 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_wasm_extension_host_wallet.py diff --git a/lnbits/core/wasm_ext/api/host.py b/lnbits/core/wasm_ext/api/host.py index 0a787bd30..e3f208956 100644 --- a/lnbits/core/wasm_ext/api/host.py +++ b/lnbits/core/wasm_ext/api/host.py @@ -50,6 +50,11 @@ from .registry import extension_api_method logger = logging.getLogger("lnbits.extensions") +def _looks_like_lnurl_pay_target(payment_request: str) -> bool: + normalized = payment_request.strip().lower() + return normalized.startswith(("lnurl", "lightning:lnurl")) or "@" in normalized + + class ExtensionHostAPI: def __init__( self, @@ -60,6 +65,7 @@ class ExtensionHostAPI: access_token: str | None = None, context: str = "user", owner_id: str | None = None, + wallet_id: str | None = None, invocation_id: str | None = None, runtime_limits: dict[str, int] | None = None, ) -> None: @@ -69,6 +75,7 @@ class ExtensionHostAPI: self.access_token = access_token self.context = context self.owner_id = sha256s(user_id) if user_id else owner_id + self.wallet_id = wallet_id self.invocation_id = invocation_id self.runtime_limits = runtime_limits or {} from .utils import ExtensionAPIUtils @@ -367,29 +374,48 @@ class ExtensionHostAPI: async def wallet_pay_invoice( self, request: PayInvoiceRequest ) -> PayInvoiceResponse: + from lnurl import LnurlResponseException + from lnbits.core.crud.wallets import get_wallet + from lnbits.core.services.lnurl import get_pr_from_lnurl from lnbits.core.services.payments import pay_invoice from lnbits.exceptions import PaymentError - if not self.user_id: + wallet = await get_wallet(request.wallet_id) + if wallet is None: + raise PermissionError("Paying invoices from this wallet is not allowed.") + if self.user_id: + if wallet.user != self.user_id: + raise PermissionError( + "Paying invoices from this wallet is not allowed." + ) + elif not (self.context == "event" and request.wallet_id == self.wallet_id): raise PermissionError( "Paying an invoice requires an authenticated user context." ) - wallet = await get_wallet(request.wallet_id) - if wallet is None or wallet.user != self.user_id: - raise PermissionError("Paying invoices from this wallet is not allowed.") - try: + payment_request = request.payment_request + if _looks_like_lnurl_pay_target(payment_request): + if request.max_sat is None: + return PayInvoiceResponse( + ok=False, + error="max_sat is required for LNURL payments.", + ) + payment_request = await get_pr_from_lnurl( + payment_request, + request.max_sat * 1000, + request.description or None, + ) payment = await pay_invoice( wallet_id=request.wallet_id, - payment_request=request.payment_request, + payment_request=payment_request, max_sat=request.max_sat, extra={"tag": self.extension_id, **request.extra}, description=request.description, tag=self.extension_id, ) - except (PaymentError, ValueError) as exc: + except (PaymentError, ValueError, LnurlResponseException) as exc: return PayInvoiceResponse(ok=False, error=str(exc)) return PayInvoiceResponse( diff --git a/lnbits/core/wasm_ext/wasm/invoke.py b/lnbits/core/wasm_ext/wasm/invoke.py index a13c968be..01c1c405f 100644 --- a/lnbits/core/wasm_ext/wasm/invoke.py +++ b/lnbits/core/wasm_ext/wasm/invoke.py @@ -83,6 +83,7 @@ async def invoke_wasm_extension_export( access_token=access_token, context=context, owner_id=owner_id, + wallet_id=wallet_id, invocation_id=invocation.id, runtime_limits=limits, ) diff --git a/tests/unit/test_wasm_extension_host_wallet.py b/tests/unit/test_wasm_extension_host_wallet.py new file mode 100644 index 000000000..12b156bc6 --- /dev/null +++ b/tests/unit/test_wasm_extension_host_wallet.py @@ -0,0 +1,90 @@ +from types import SimpleNamespace + +import pytest + +from lnbits.core.wasm_ext.api.host import ExtensionHostAPI +from lnbits.core.wasm_ext.api.models import PayInvoiceRequest + + +@pytest.mark.anyio +async def test_wasm_wallet_pay_invoice_resolves_ln_address(mocker): + calls = {} + + async def get_wallet(wallet_id: str): + calls["get_wallet"] = wallet_id + return SimpleNamespace(user="user1") + + async def get_pr_from_lnurl(lnurl: str, amount_msat: int, comment: str | None): + calls["lnurl"] = (lnurl, amount_msat, comment) + return "lnbc1resolved" + + async def pay_invoice(**kwargs): + calls["pay_invoice"] = kwargs + return SimpleNamespace( + checking_id="checking", + payment_hash="hash", + status="success", + amount=-21_000, + fee=-10, + pending=False, + success=True, + ) + + mocker.patch("lnbits.core.crud.wallets.get_wallet", get_wallet) + mocker.patch("lnbits.core.services.lnurl.get_pr_from_lnurl", get_pr_from_lnurl) + mocker.patch("lnbits.core.services.payments.pay_invoice", pay_invoice) + + api = ExtensionHostAPI("demoext", ["wallet.pay_invoice"], user_id="user1") + response = await api.wallet_pay_invoice( + PayInvoiceRequest( + wallet_id="wallet1", + payment_request="alice@example.com", + max_sat=21, + description="winner", + ) + ) + + assert response.ok is True + assert response.checking_id == "checking" + assert calls["get_wallet"] == "wallet1" + assert calls["lnurl"] == ("alice@example.com", 21_000, "winner") + assert calls["pay_invoice"]["payment_request"] == "lnbc1resolved" + assert calls["pay_invoice"]["max_sat"] == 21 + + +@pytest.mark.anyio +async def test_wasm_wallet_pay_invoice_allows_event_wallet_only(mocker): + async def get_wallet(wallet_id: str): + return SimpleNamespace(user="other-user") + + async def pay_invoice(**kwargs): + return SimpleNamespace( + checking_id="checking", + payment_hash="hash", + status="success", + amount=-1_000, + fee=0, + pending=False, + success=True, + ) + + mocker.patch("lnbits.core.crud.wallets.get_wallet", get_wallet) + mocker.patch("lnbits.core.services.payments.pay_invoice", pay_invoice) + + api = ExtensionHostAPI( + "demoext", + ["wallet.pay_invoice"], + context="event", + owner_id="owner", + wallet_id="wallet1", + ) + allowed = await api.wallet_pay_invoice( + PayInvoiceRequest(wallet_id="wallet1", payment_request="lnbc1invoice") + ) + + assert allowed.ok is True + + with pytest.raises(PermissionError, match="authenticated user context"): + await api.wallet_pay_invoice( + PayInvoiceRequest(wallet_id="wallet2", payment_request="lnbc1invoice") + )