From ed8118aa1ee1c5287c1bafcc06cad4746608ff0d Mon Sep 17 00:00:00 2001 From: arbadacarba <63317640+arbadacarbaYK@users.noreply.github.com> Date: Sun, 1 Oct 2023 09:54:50 +0200 Subject: [PATCH 01/68] Allow only lowercase for lightning-address (#25) because uppercase fails --- templates/lnurlp/index.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/templates/lnurlp/index.html b/templates/lnurlp/index.html index 103ae1b..544b332 100644 --- a/templates/lnurlp/index.html +++ b/templates/lnurlp/index.html @@ -105,7 +105,10 @@
- + + +   @ {% raw %} {{ domain }} {% endraw %} +
@@ -250,4 +253,4 @@
{% endblock %} {% block scripts %} {{ window_vars(user) }} -{% endblock %} \ No newline at end of file +{% endblock %} From 8bad631fb6636e57acaaa223cb882df4a8d9772c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Sun, 1 Oct 2023 09:55:21 +0200 Subject: [PATCH 02/68] bug: update lnurlp without username (#24) * bug: update lnurlp without username thrown an exception ``` if len(kwargs["username"]) > 0: TypeError: object of type 'NoneType' has no len() 2023-09-29 08:54:04.79 | ERROR | lnbits.app:exception_handler:460 | Exception: object of type 'NoneType' has no len() ``` * nicer if --- crud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crud.py b/crud.py index 4bbde29..de76cce 100644 --- a/crud.py +++ b/crud.py @@ -105,7 +105,7 @@ async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: async def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: - if len(kwargs["username"]) > 0: + if "username" in kwargs and len(kwargs["username"]) > 0: await check_lnaddress_format(kwargs["username"]) await check_lnaddress_not_exists(kwargs["username"]) From 257f5d34d277eb0027ebabc00ef97a98e9ebb239 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Nov 2023 16:09:32 +0200 Subject: [PATCH 03/68] Fix update validations (#30) * fix: no `username` update * fix: update for old pay_links * chore: code format --- README.md | 8 +- __init__.py | 5 +- crud.py | 3 +- lnurl.py | 13 +- models.py | 2 +- static/js/index.js | 2 +- tasks.py | 14 +- templates/lnurlp/index.html | 297 ++++++++++++++++++++++++++++-------- views_api.py | 2 - 9 files changed, 252 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 0c6021c..a3f4be3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # LNURLp - [LNbits](https://github.com/lnbits/lnbits) extension + For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions) ## Create a static QR code or LNaddress people can use to pay over Lightning Network @@ -36,10 +37,10 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n ## Update your LNURL-pay extension Now that the extensions are taken out of core LNbits we can update each extension separately without the need to reload or restart LNbits as a whole. -This new version of the extension will give you the option to add a Lightning Address to each LNURLpay link. +This new version of the extension will give you the option to add a Lightning Address to each LNURLpay link. - Open your LNbits instance as super admin (not as a regular user. You will find the SuperUser-ID in your server logs on restart of LNbits. Use that to bookmark and manage LNbits from there in the future.) -Now lets install the new version of a given extension like extensively [described in this guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/extension-install.md#install-new-extension). In short: + Now lets install the new version of a given extension like extensively [described in this guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/extension-install.md#install-new-extension). In short: - Go to "Mange extensions", click on "ALL", search for e.g. LNURLp, click on "Manage" - Open the details of the extension and click on version 0.2.1, click "Install". You´re done! @@ -55,7 +56,4 @@ Now you can receive sats to your newly created LN address. You will find this in [![lnurl-details.jpg](https://i.postimg.cc/zDwq1V2X/lnurl-details.jpg)](https://postimg.cc/3WwsXJHP) - - - diff --git a/__init__.py b/__init__.py index 291111b..5c0ccdb 100644 --- a/__init__.py +++ b/__init__.py @@ -1,17 +1,16 @@ import asyncio from typing import List +from environs import Env from fastapi import APIRouter +from loguru import logger from lnbits.db import Database from lnbits.helpers import template_renderer from lnbits.tasks import catch_everything_and_restart -from loguru import logger - from .nostr.event import Event from .nostr.key import PrivateKey, PublicKey -from environs import Env def generate_keys(private_key: str = ""): diff --git a/crud.py b/crud.py index de76cce..377484d 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,3 @@ -import re from typing import List, Optional, Union from lnbits.helpers import urlsafe_short_hash @@ -105,7 +104,7 @@ async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: async def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: - if "username" in kwargs and len(kwargs["username"]) > 0: + if "username" in kwargs and len(kwargs["username"] or "") > 0: await check_lnaddress_format(kwargs["username"]) await check_lnaddress_not_exists(kwargs["username"]) diff --git a/lnurl.py b/lnurl.py index 4bcfea3..d3703b1 100644 --- a/lnurl.py +++ b/lnurl.py @@ -3,18 +3,13 @@ from urllib.parse import urlparse from fastapi import Query, Request from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse -from loguru import logger from starlette.exceptions import HTTPException from lnbits.core.services import create_invoice from lnbits.utils.exchange_rates import get_fiat_rate_satoshis -from . import lnurlp_ext -from .crud import increment_pay_link, get_pay_link, get_address_data -from loguru import logger -from urllib.parse import urlparse -import json -from . import nostr_publickey +from . import lnurlp_ext, nostr_publickey +from .crud import increment_pay_link @lnurlp_ext.get( @@ -132,7 +127,9 @@ async def api_lnurl_response(request: Request, link_id, lnaddress=False): if lnaddress: # for lnaddress, we have to set this otherwise the metadata won't have the identifier link.domain = urlparse(str(request.url)).netloc - callback = str(request.url_for("lnurlp.api_lnurl_lnaddr_callback", link_id=link.id)) + callback = str( + request.url_for("lnurlp.api_lnurl_lnaddr_callback", link_id=link.id) + ) else: callback = str(request.url_for("lnurlp.api_lnurl_callback", link_id=link.id)) diff --git a/models.py b/models.py index aae9b1b..e710558 100644 --- a/models.py +++ b/models.py @@ -24,7 +24,7 @@ class CreatePayLinkData(BaseModel): success_url: str = Query(None) fiat_base_multiplier: int = Query(100, ge=1) username: str = Query(None) - zaps: bool = Query(False) + zaps: Optional[bool] = Query(False) class PayLink(BaseModel): diff --git a/static/js/index.js b/static/js/index.js index 4c09507..ffbb53a 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -41,7 +41,7 @@ new Vue({ show: false, fixedAmount: true, data: { - zaps:false + zaps: false } }, qrCodeDialog: { diff --git a/tasks.py b/tasks.py index af736d5..d44948d 100644 --- a/tasks.py +++ b/tasks.py @@ -1,23 +1,21 @@ import asyncio import json +import time +from threading import Thread +from typing import List import httpx from loguru import logger +from websocket import WebSocketApp from lnbits.core.crud import update_payment_extra from lnbits.core.models import Payment from lnbits.helpers import get_current_extension_name from lnbits.tasks import register_invoice_listener -from websocket import WebSocketApp -from lnbits.settings import settings -from .crud import get_pay_link -from threading import Thread -from . import nostr_privatekey -from typing import List -import time +from . import nostr_privatekey +from .crud import get_pay_link from .nostr.event import Event -from .nostr.key import PrivateKey, PublicKey async def wait_for_paid_invoices(): diff --git a/templates/lnurlp/index.html b/templates/lnurlp/index.html index 544b332..914b0fc 100644 --- a/templates/lnurlp/index.html +++ b/templates/lnurlp/index.html @@ -4,7 +4,9 @@
- New pay link + New pay link @@ -15,7 +17,13 @@
Pay links
- + {% raw %} @@ -402,10 +400,17 @@ " /> -
- -  @  - +
+  @  +
+
+
@@ -642,7 +647,7 @@
Lightning Address: - +

diff --git a/views_api.py b/views_api.py index ad516d3..eac8a97 100644 --- a/views_api.py +++ b/views_api.py @@ -23,15 +23,15 @@ from .crud import ( update_lnurlp_settings, update_pay_link, ) -from .helpers import lnurl_encode_link_id, parse_nostr_private_key -from .models import CreatePayLinkData, LnurlpSettings, PayLink +from .helpers import lnurl_encode_link, parse_nostr_private_key +from .models import CreatePayLinkData, LnurlpSettings, PayLink, PublicPayLink lnurlp_api_router = APIRouter() -def check_lnurl_encode(req: Request, link_id: str) -> str: +def check_lnurl_encode(req: Request, link: PayLink) -> str: try: - return lnurl_encode_link_id(req, link_id) + return lnurl_encode_link(req, link.id, link.domain) except InvalidUrl as exc: raise HTTPException( detail=( @@ -60,11 +60,11 @@ async def api_links( links = await get_pay_links(wallet_ids) for link in links: - link.lnurl = check_lnurl_encode(req=req, link_id=link.id) + link.lnurl = check_lnurl_encode(req, link) return links -@lnurlp_api_router.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +@lnurlp_api_router.get("/api/v1/links/{link_id}") async def api_link_retrieve( req: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key) ) -> PayLink: @@ -85,7 +85,18 @@ async def api_link_retrieve( detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN ) - link.lnurl = check_lnurl_encode(req, link.id) + link.lnurl = check_lnurl_encode(req, link) + return link + + +@lnurlp_api_router.get("/api/v1/links/public/{link_id}", response_model=PublicPayLink) +async def api_link_public_retrieve(req: Request, link_id: str) -> PayLink: + link = await get_pay_link(link_id) + if not link: + raise HTTPException( + detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + link.lnurl = lnurl_encode_link(req, link.id, link.domain) return link @@ -168,7 +179,7 @@ async def api_link_create_or_update( detail="Wallet does not exist.", status_code=HTTPStatus.FORBIDDEN ) - # admins are allowed to create/edit paylinks beloging to regular users + # admins are allowed to create/edit paylinks belonging to regular users user = await get_user(key_info.wallet.user) admin_user = user.admin if user else False if not admin_user and new_wallet.user != key_info.wallet.user: @@ -197,8 +208,7 @@ async def api_link_create_or_update( link = await create_pay_link(data) - link.lnurl = check_lnurl_encode(req=req, link_id=link.id) - + link.lnurl = check_lnurl_encode(req, link) return link diff --git a/views_lnurl.py b/views_lnurl.py index adc88b4..475b57f 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -99,7 +99,7 @@ async def api_lnurl_callback( extra["nostr"] = nostr # put it here for later publishing in tasks.py if link.username: - identifier = f"{link.username}@{request.url.netloc}" + identifier = f"{link.username}@{link.domain or request.url.netloc}" text = f"Payment to {link.username}" _metadata = [["text/plain", text], ["text/identifier", identifier]] extra["lnaddress"] = identifier @@ -173,7 +173,7 @@ async def api_lnurl_response( callback_url = parse_obj_as(CallbackUrl, str(url)) if link.username: - identifier = f"{link.username}@{request.url.netloc}" + identifier = f"{link.username}@{link.domain or request.url.netloc}" text = f"Payment to {link.username}" metadata = [["text/plain", text], ["text/identifier", identifier]] else: From 6d8ee6601900900b69bf43861a66b59f314220d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Thu, 15 Jan 2026 10:21:17 +0100 Subject: [PATCH 64/68] feat: add copy and qrcode for lnaddress (#124) closes #121 --- static/index.vue | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/static/index.vue b/static/index.vue index df4ea7d..4352a3a 100644 --- a/static/index.vue +++ b/static/index.vue @@ -648,6 +648,20 @@ Lightning Address: + + + + + +

From 9281cb74fb8f7a70a32e16e1020f94ca635c90c1 Mon Sep 17 00:00:00 2001 From: DoktorShift <106493492+DoktorShift@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:36:59 +0100 Subject: [PATCH 65/68] doc: Changes to more pages (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Changes to more pages --------- Co-authored-by: dni ⚡ --- README.md | 17 +++++++++++++++++ config.json | 13 ++++++++++--- description.md | 11 ++++++++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 72fe035..9fcef84 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ + + + + LNbits + + + +[![License: MIT](https://img.shields.io/badge/License-MIT-success?logo=open-source-initiative&logoColor=white)](./LICENSE) +[![Built for LNbits](https://img.shields.io/badge/Built%20for-LNbits-4D4DFF?logo=lightning&logoColor=white)](https://github.com/lnbits/lnbits) + # LNURLp - [LNbits](https://github.com/lnbits/lnbits) extension For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions) @@ -55,3 +65,10 @@ Now you can receive sats to your newly created LN address. You will find this in [![lnurl-details.jpg](https://i.postimg.cc/zDwq1V2X/lnurl-details.jpg)](https://postimg.cc/3WwsXJHP) + +## Powered by LNbits + +[LNbits](https://lnbits.com) is a free and open-source lightning accounts system. + +[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) +[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) diff --git a/config.json b/config.json index 41a398f..5742544 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,10 @@ { - "name": "Pay Links", + "id": "paylink", "version": "1.3.0", - "short_description": "Make reusable LNURL pay links", + "name": "Pay Links", + "repo": "https://github.com/lnbits/lnurlp", + "short_description": "Make static reusable LNURL pay links or lightning addresses", + "description": "", "tile": "/lnurlp/static/image/lnurl-pay.png", "min_lnbits_version": "1.4.0", "contributors": [ @@ -51,5 +54,9 @@ ], "description_md": "https://raw.githubusercontent.com/lnbits/lnurlp/main/description.md", "terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/lnurlp/main/toc.md", - "license": "MIT" + "license": "MIT", + "paid_features": "", + "tags": ["Merchant", "Payments"], + "donate": "", + "hidden": false } diff --git a/description.md b/description.md index 3a81e0d..a4cdd4f 100644 --- a/description.md +++ b/description.md @@ -1 +1,10 @@ -Create a static LNURLp or LNaddress people can use to pay. +Create static LNURL-pay links and Lightning addresses for receiving payments. + +Its functions include: + +- Generating reusable LNURL-pay QR codes +- Creating custom Lightning addresses +- Setting minimum and maximum payment amounts +- Adding payment descriptions and metadata + +A foundational tool for anyone who wants a simple, reusable way to receive Lightning payments with a static QR code or Lightning address. From dc37e259ba4bd157e936da8b5c42b88e43325909 Mon Sep 17 00:00:00 2001 From: DoktorShift <106493492+DoktorShift@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:12:29 +0200 Subject: [PATCH 66/68] remove double slash in LNURL pay endpoint URL (#129) --- static/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/index.js b/static/index.js index d11f7d7..497d2b6 100644 --- a/static/index.js +++ b/static/index.js @@ -150,7 +150,7 @@ window.PageLnurlp = { username: link.username } const domain = link.domain || window.location.host - this.activeUrl = `https://${domain}/lnurlp//${link.id}` + this.activeUrl = `https://${domain}/lnurlp/${link.id}` this.qrCodeDialog.show = true }, openUpdateDialog(linkId) { From d299e15c2fee294ee6dcd7e5c8f788879c359aa4 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Fri, 8 May 2026 05:32:48 +0100 Subject: [PATCH 67/68] wait for zap receipt (#133) --- tasks.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tasks.py b/tasks.py index c03e6ce..5c1decd 100644 --- a/tasks.py +++ b/tasks.py @@ -144,6 +144,16 @@ async def send_zap(payment: Payment): async with websockets.connect(relay_url, open_timeout=5) as websocket: logger.debug(f"Sending zap to {relay_url}") await websocket.send(event_message) + response = await asyncio.wait_for(websocket.recv(), timeout=5) + relay_response = json.loads(response) + if relay_response[0] != "OK" or not relay_response[2]: + logger.debug( + f"Relay did not acknowledge zap receipt: {relay_response}" + ) + return + logger.debug(f"Zap sent to {relay_url} successfully") + except asyncio.TimeoutError: + logger.debug(f"Relay did not acknowledge zap receipt: {relay_url}") except Exception as e: logger.warning(f"Failed to send zap to {relay_url}: {e}") From 31cf2eb164c3513fc49511efb3c54b366701e37a Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 13 May 2026 08:46:17 +0200 Subject: [PATCH 68/68] feat: register transport RPCs over LNbits nostr transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors what aiolabs/withdraw did — hooks lnurlp's existing CRUD into the LNbits nostr transport layer so an HTTP-allergic client (e.g. lamassu-next ATM) can manage PayLinks over kind-21000 encrypted events instead of HTTP. Extends the existing `lnurlp_start()` lifecycle hook (auto-invoked by the LNbits extension manager) to import the transport's `register_rpc` and register five RPCs: lnurlp_create AUTH_WALLET lnurlp_get AUTH_WALLET lnurlp_list AUTH_ACCOUNT lnurlp_update AUTH_WALLET lnurlp_delete AUTH_WALLET All handlers are thin shims around the existing crud.py functions — no business logic duplication. *_get / *_update / *_delete verify that the link's stored wallet matches the caller's wallet id. Also registers a link-owner resolver with the core subscriptions module (tag "lnurlp", extras-key "link" — the default, matching where views_lnurl.py:86 stamps the link id on settlement). That lets clients call `subscribe_payments({tag:"lnurlp", link_id:...})` and stream real-time pay events without polling, with ownership enforced server-side. The transport import is guarded by try/except ImportError so this extension still loads cleanly against an LNbits build that doesn't have nostr_transport. Co-Authored-By: Claude Opus 4.7 (1M context) --- __init__.py | 36 +++++++++++ transport_rpcs.py | 151 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 transport_rpcs.py diff --git a/__init__.py b/__init__.py index 27ca96e..782315b 100644 --- a/__init__.py +++ b/__init__.py @@ -42,6 +42,42 @@ def lnurlp_start(): task = create_permanent_unique_task("lnurlp", wait_for_paid_invoices) scheduled_tasks.append(task) + # Expose lnurlp's CRUD over the LNbits nostr transport so an HTTP- + # allergic client (e.g. lamassu-next ATM) can manage PayLinks over + # kind-21000 encrypted events. Also wires the link-owner resolver so + # `subscribe_payments({tag:"lnurlp", link_id:...})` can verify + # ownership of the underlying wallet. No-op if the core transport + # module isn't present in the LNbits build. + try: + from lnbits.core.services.nostr_transport.dispatcher import ( + AUTH_ACCOUNT, + AUTH_WALLET, + register_rpc, + ) + from lnbits.core.services.nostr_transport.subscriptions import ( + register_link_owner_resolver, + ) + except ImportError: + return + + from .transport_rpcs import ( + handle_lnurlp_create, + handle_lnurlp_delete, + handle_lnurlp_get, + handle_lnurlp_list, + handle_lnurlp_update, + resolve_lnurlp_owner, + ) + + register_rpc("lnurlp_create", handle_lnurlp_create, AUTH_WALLET) + register_rpc("lnurlp_get", handle_lnurlp_get, AUTH_WALLET) + register_rpc("lnurlp_list", handle_lnurlp_list, AUTH_ACCOUNT) + register_rpc("lnurlp_update", handle_lnurlp_update, AUTH_WALLET) + register_rpc("lnurlp_delete", handle_lnurlp_delete, AUTH_WALLET) + # lnurlp stamps `extra["link"] = link.id` on settlement + # (views_lnurl.py:86), which is the default extras-key, so no override. + register_link_owner_resolver("lnurlp", resolve_lnurlp_owner) + __all__ = [ "db", diff --git a/transport_rpcs.py b/transport_rpcs.py new file mode 100644 index 0000000..87f3f59 --- /dev/null +++ b/transport_rpcs.py @@ -0,0 +1,151 @@ +""" +Nostr-transport RPC handlers for the lnurlp (LNURL-pay) extension. + +Exposes the same CRUD surface that `views_api.py` exposes via HTTP, but +encrypted over kind-21000 events through the LNbits nostr transport. +Mirrors the withdraw extension's `transport_rpcs.py`; both extensions +hang their handlers off the core dispatcher via their `*_start()` hook. + +Auth model (set by the registrations in `__init__.py:lnurlp_start`): +- *_create / *_get / *_update / *_delete → AUTH_WALLET. The transport + resolves the caller's pubkey to a wallet (admin access) before + invoking the handler, so we know `auth.wallet.id` and `auth.wallet.user`. +- *_list → AUTH_ACCOUNT. The caller may list links across all wallets + they own, optionally narrowed by `request.wallet_id`. + +Ownership: *_get / *_update / *_delete also verify the link's stored +`wallet` field matches the caller's wallet id — defense in depth, since +a malicious client could otherwise probe link metadata they don't own. + +`resolve_lnurlp_owner` is registered with the core subscription module +under tag `"lnurlp"` (default link_extra_key `"link"` — that's where +`views_lnurl.py:86` stamps the link id on settlement). That lets clients +call `subscribe_payments({tag:"lnurlp", link_id:...})` and stream real- +time pay events without polling, with ownership enforced server-side. +""" + +from __future__ import annotations + +from lnbits.core.crud.wallets import get_wallets +from lnbits.core.models import Account +from lnbits.core.models.wallets import WalletTypeInfo +from lnbits.core.services.nostr_transport.models import NostrRpcRequest + +from .crud import ( + create_pay_link, + delete_pay_link, + get_pay_link, + get_pay_links, + update_pay_link, +) +from .models import CreatePayLinkData + + +async def handle_lnurlp_create( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + body = request.body or {} + body["wallet"] = auth.wallet.id # always create under the calling wallet + data = CreatePayLinkData(**body) + link = await create_pay_link(data) + return _to_dict(link) + + +async def handle_lnurlp_get( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + link_id = _require_id(request) + link = await _require_owned_link(link_id, auth.wallet.id) + return _to_dict(link) + + +async def handle_lnurlp_list( + auth: Account, request: NostrRpcRequest +) -> list[dict]: + """List PayLinks across all wallets owned by the calling account. + If `request.wallet_id` is set and is one of those wallets, narrow to + just that wallet.""" + wallets = await get_wallets(auth.id) + wallet_ids = [w.id for w in wallets] + if not wallet_ids: + return [] + if request.wallet_id and request.wallet_id in wallet_ids: + wallet_ids = [request.wallet_id] + links = await get_pay_links(wallet_ids) + return [_to_dict(link) for link in links] + + +async def handle_lnurlp_update( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + link_id = _require_id(request) + link = await _require_owned_link(link_id, auth.wallet.id) + body = request.body or {} + # Only patchable fields. Identity / counter fields (id, wallet, + # served_meta, served_pr, created_at) are not client-mutable. + _MUTABLE = { + "description", + "min", + "max", + "comment_chars", + "currency", + "webhook_url", + "webhook_headers", + "webhook_body", + "success_text", + "success_url", + "fiat_base_multiplier", + "username", + "zaps", + "disposable", + "domain", + } + for k, v in body.items(): + if k in _MUTABLE: + setattr(link, k, v) + updated = await update_pay_link(link) + return _to_dict(updated) + + +async def handle_lnurlp_delete( + auth: WalletTypeInfo, request: NostrRpcRequest +) -> dict: + link_id = _require_id(request) + await _require_owned_link(link_id, auth.wallet.id) + await delete_pay_link(link_id) + return {"ok": True} + + +async def resolve_lnurlp_owner(link_id: str) -> str | None: + """For the core subscription module: link_id -> wallet_id (or None).""" + link = await get_pay_link(link_id) + return link.wallet if link else None + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _require_id(request: NostrRpcRequest) -> str: + body = request.body or {} + link_id = body.get("id") + if not link_id: + raise ValueError("lnurlp: body.id is required") + return str(link_id) + + +async def _require_owned_link(link_id: str, wallet_id: str): + link = await get_pay_link(link_id) + if link is None: + raise ValueError(f"lnurlp: link not found: {link_id}") + if link.wallet != wallet_id: + raise PermissionError( + "lnurlp: link does not belong to caller's wallet" + ) + return link + + +def _to_dict(link) -> dict: + import json + return json.loads(link.json())