Compare commits

..

31 commits

Author SHA1 Message Date
31cf2eb164 feat: register transport RPCs over LNbits nostr transport
Some checks failed
lint.yml / feat: register transport RPCs over LNbits nostr transport (push) Failing after 0s
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) <noreply@anthropic.com>
2026-05-13 08:46:17 +02:00
Tiago Vasconcelos
d299e15c2f
wait for zap receipt (#133)
Some checks failed
lint.yml / wait for zap receipt (#133) (push) Failing after 0s
2026-05-08 06:32:48 +02:00
DoktorShift
dc37e259ba
remove double slash in LNURL pay endpoint URL (#129) 2026-04-16 13:12:29 +02:00
DoktorShift
9281cb74fb
doc: Changes to more pages (#125)
* Changes to more pages

---------

Co-authored-by: dni  <office@dnilabs.com>
2026-01-27 11:36:59 +01:00
dni ⚡
6d8ee66019
feat: add copy and qrcode for lnaddress (#124)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
closes #121
2026-01-15 10:21:17 +01:00
dni ⚡
17135b45ae
feat: add optional domain field (#120)
* feat: add optional domain field
closes #119
2026-01-15 09:20:54 +01:00
dni ⚡
76c5841bc8
fix: gracefully handle Lnurl errors on api (#123)
* fix: gracefully handle InvalidLnurl error on api

closes #122
2026-01-15 09:09:21 +01:00
dni ⚡
33b06bcd9b
feat: use 1.4.0 dynamic extension loading (#116)
* feat: use 1.4.0 dynamic extension loading

and go through extension todo:
https://github.com/lnbits/lnbits/issues/3652
2025-12-17 13:06:28 +01:00
dni ⚡
a1a55cb974
fix: add a tag when zapping (#115) 2025-12-16 07:58:20 +01:00
dni ⚡
407955dce5
chore: update to version 1.2.0 and min version 1.4.0 (#113)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-12-02 09:21:55 +01:00
dni ⚡
c4d923e9af
refactor: pynostr instead of custom nostr lib (#112) 2025-11-20 10:12:18 +01:00
arbadacarba
9a152723e2
Fix typo in installation instructions (#109) 2025-10-16 17:22:27 +02:00
dni ⚡
ef1171bb47
chore: update to v1.1.3 (#106)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-10-06 09:51:17 +02:00
Vlad Stan
b1c92a067d
[fix] add lnurl field back (#105) 2025-10-06 10:29:09 +03:00
Vlad Stan
db8de4804d
fix: use checking_id instead of payment_hash (#104) 2025-10-03 15:16:15 +03:00
dni ⚡
6aeabd2036
feat: use new lnbits-qrcode-lnurl component (#103)
* feat: use new lnbits-qrcode-lnurl component
2025-09-04 06:59:18 +02:00
dni ⚡
dd703baba8
chore: update to version v1.1.2
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-08-21 17:18:57 +02:00
dni ⚡
e48be221b1
chore: update lnurl lib to v0.8.0 (#102) 2025-08-21 17:15:17 +02:00
Tiago Vasconcelos
a242f0e4b6
fix: paylinks in fiat (#101)
* fix unreachable code
* trying to fix fiat pay links
* don't drop support for python 3.10
* values are store as int when there's currency
* simplify js
* add hint for fiat denom
2025-08-21 10:29:32 +02:00
dni ⚡
a8e8658ff5
chore: update to version v1.1.1
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-08-18 12:14:41 +02:00
dni ⚡
4784ebc9f2
feat: remove lnurl from api (#91) 2025-08-18 10:55:13 +02:00
dni ⚡
1d91b50a67
refactor: dont use model property for lnurlpaymetadata (#97)
* refactor: dont use model property for lnurlpaymetadata
domain is really only needed if you do it with via the property. cleans
up code and makes it more readable
2025-08-18 10:49:25 +02:00
dni ⚡
860d281a18
chore: properly define model types (#94) 2025-08-18 10:42:06 +02:00
dni ⚡
1cebe56707
chore: update to lnbits 1.3.0-rc3 (#99) 2025-08-18 10:34:01 +02:00
dni ⚡
1805b6d635
fix: checkbox disposable default (#98)
was not set
2025-08-18 08:38:32 +02:00
dni ⚡
3dc1e86d8c
feat: support lud-11 disposable links (#95) 2025-08-14 12:16:16 +02:00
dni ⚡
0cf2df2dcb
refactor: use core currency and rate endpoints (#93) 2025-08-14 12:12:14 +02:00
dni ⚡
f9ee067e88
fix: broken lnurl error message on amount limits (#96) 2025-08-14 12:12:00 +02:00
dni ⚡
5a0d7ee4ed
fix: linting issues introduced last commit (#92) 2025-08-14 09:35:58 +02:00
Sat
7eea2330c9
fix(tasks): Refactor send_zap to use async websockets and prevent crashes (#90)
* fix(tasks): Make send_zap non-blocking to prevent freezes

The send_zap async function contained blocking calls (thread.join()) which halted the main asyncio event loop. This caused the application to become unresponsive or "freeze" until all zap receipt threads completed.

Refactored the function to be fully non-blocking by removing the join() calls and the arbitrary sleep(). Zap receipts are now dispatched in background threads on a fire-and-forget basis, allowing the main application to remain responsive.

* fix(tasks): Use async websockets in send_zap to prevent crashes

Refactor to replace the threading and websocket-client logic with the native asyncio websockets library. 

Create a non-blocking asyncio task for each relay,
2025-08-13 14:07:18 +02:00
dni ⚡
1455afa219
feat: add lud17 urls to qr / copy (#89)
* feat: add lud17 urls to qr / copy
2025-07-23 11:32:13 +02:00
33 changed files with 3646 additions and 5107 deletions

View file

@ -5,27 +5,27 @@ format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier
prettier:
poetry run ./node_modules/.bin/prettier --write .
uv run ./node_modules/.bin/prettier --write .
pyright:
poetry run ./node_modules/.bin/pyright
uv run ./node_modules/.bin/pyright
mypy:
poetry run mypy .
uv run mypy .
black:
poetry run black .
uv run black .
ruff:
poetry run ruff check . --fix
uv run ruff check . --fix
checkruff:
poetry run ruff check .
uv run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check .
uv run ./node_modules/.bin/prettier --check .
checkblack:
poetry run black --check .
uv run black --check .
checkeditorconfig:
editorconfig-checker
@ -33,14 +33,14 @@ checkeditorconfig:
test:
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
uv run pytest
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
@echo "Uninstall the hook with uv run pre-commit uninstall"
uv run pre-commit install
pre-commit:
poetry run pre-commit run --all-files
uv run pre-commit run --all-files
checkbundle:

View file

@ -1,3 +1,13 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:280px">
</picture>
</a>
[![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 - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
@ -12,7 +22,6 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n
1. Create an LNURLp (New Pay link)\
![create lnurlp](https://i.imgur.com/rhUBJFy.jpg)
- select your wallets
- make a small description
- enter amount
@ -25,7 +34,6 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n
2. Use the shareable link or view the LNURLp you just created\
![LNURLp](https://i.imgur.com/C8s1P0Q.jpg)
- you can now open your LNURLp and copy the LNURL, get the shareable link or print it\
![view lnurlp](https://i.imgur.com/4n41S7T.jpg)
@ -41,7 +49,7 @@ This new version of the extension will give you the option to add a Lightning Ad
- 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:
- Go to "Mange extensions", click on "ALL", search for e.g. LNURLp, click on "Manage"
- Go to "Manage 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!
[![lnurl-p-1.jpg](https://i.postimg.cc/fTwDWD17/lnurl-p-1.jpg)](https://postimg.cc/xqFWtDfq)
@ -57,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)
</details>
## 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)

View file

@ -1,5 +1,4 @@
import asyncio
from typing import List
from fastapi import APIRouter
@ -29,7 +28,7 @@ lnurlp_ext.include_router(lnurlp_generic_router)
lnurlp_ext.include_router(lnurlp_api_router)
lnurlp_ext.include_router(lnurlp_lnurl_router)
scheduled_tasks: List[asyncio.Task] = []
scheduled_tasks: list[asyncio.Task] = []
def lnurlp_stop():
@ -43,12 +42,48 @@ 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__ = [
"lnurlp_ext",
"lnurlp_static_files",
"lnurlp_redirect_paths",
"lnurlp_stop",
"lnurlp_start",
"db",
"lnurlp_ext",
"lnurlp_redirect_paths",
"lnurlp_start",
"lnurlp_static_files",
"lnurlp_stop",
]

View file

@ -1,9 +1,12 @@
{
"id": "paylink",
"version": "1.3.0",
"name": "Pay Links",
"version": "1.1.0",
"short_description": "Make reusable LNURL 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.3.0",
"min_lnbits_version": "1.4.0",
"contributors": [
{
"name": "arcbtc",
@ -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
}

14
crud.py
View file

@ -1,11 +1,10 @@
from datetime import datetime, timezone
from typing import List, Optional, Union
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from pynostr.key import PrivateKey
from .models import CreatePayLinkData, LnurlpSettings, PayLink
from .nostr.key import PrivateKey
db = Database("ext_lnurlp")
@ -31,7 +30,7 @@ async def delete_lnurlp_settings() -> None:
await db.execute("DELETE FROM lnurlp.settings")
async def get_pay_link_by_username(username: str) -> Optional[PayLink]:
async def get_pay_link_by_username(username: str) -> PayLink | None:
return await db.fetchone(
"SELECT * FROM lnurlp.pay_links WHERE username = :username",
{"username": username},
@ -55,7 +54,6 @@ async def create_pay_link(data: CreatePayLinkData) -> PayLink:
served_pr=0,
username=data.username,
zaps=data.zaps,
domain=None,
webhook_url=data.webhook_url,
webhook_headers=data.webhook_headers,
webhook_body=data.webhook_body,
@ -66,13 +64,15 @@ async def create_pay_link(data: CreatePayLinkData) -> PayLink:
fiat_base_multiplier=data.fiat_base_multiplier,
created_at=now,
updated_at=now,
disposable=data.disposable if data.disposable is not None else True,
domain=data.domain,
)
await db.insert("lnurlp.pay_links", link)
return link
async def get_address_data(username: str) -> Optional[PayLink]:
async def get_address_data(username: str) -> PayLink | None:
return await db.fetchone(
"SELECT * FROM lnurlp.pay_links WHERE username = :username",
{"username": username},
@ -80,7 +80,7 @@ async def get_address_data(username: str) -> Optional[PayLink]:
)
async def get_pay_link(link_id: str) -> Optional[PayLink]:
async def get_pay_link(link_id: str) -> PayLink | None:
return await db.fetchone(
"SELECT * FROM lnurlp.pay_links WHERE id = :id",
{"id": link_id},
@ -88,7 +88,7 @@ async def get_pay_link(link_id: str) -> Optional[PayLink]:
)
async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
async def get_pay_links(wallet_ids: str | list[str]) -> list[PayLink]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])

View file

@ -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.

View file

@ -1,4 +1,6 @@
from .nostr.key import PrivateKey
from fastapi import Request
from lnurl import encode as lnurl_encode
from pynostr.key import PrivateKey
def parse_nostr_private_key(key: str) -> PrivateKey:
@ -6,3 +8,18 @@ def parse_nostr_private_key(key: str) -> PrivateKey:
return PrivateKey.from_nsec(key)
else:
return PrivateKey(bytes.fromhex(key))
def lnurl_encode_link(req: Request, link_id: str, domain: str | None = None) -> str:
if domain:
url_str = f"https://{domain}/lnurlp/{link_id}"
return str(lnurl_encode(url_str).bech32)
url = req.url_for("lnurlp.api_lnurl_response", link_id=link_id)
url = url.replace(path=url.path)
url_str = str(url)
if url.netloc.endswith(".onion"):
# change url string scheme to http
url_str = url_str.replace("https://", "http://")
return str(lnurl_encode(url_str).bech32)

View file

@ -7,8 +7,7 @@ async def m001_initial(db):
"""
Initial pay table.
"""
await db.execute(
f"""
await db.execute(f"""
CREATE TABLE lnurlp.pay_links (
id {db.serial_primary_key},
wallet TEXT NOT NULL,
@ -17,8 +16,7 @@ async def m001_initial(db):
served_meta INTEGER NOT NULL,
served_pr INTEGER NOT NULL
);
"""
)
""")
async def m002_webhooks_and_success_actions(db):
@ -28,16 +26,14 @@ async def m002_webhooks_and_success_actions(db):
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_url TEXT;")
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_text TEXT;")
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;")
await db.execute(
f"""
await db.execute(f"""
CREATE TABLE lnurlp.invoices (
pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id),
payment_hash TEXT NOT NULL,
webhook_sent INT, -- null means not sent, otherwise store status
expiry INT
);
"""
)
""")
async def m003_min_max_comment_fiat(db):
@ -86,8 +82,7 @@ async def m006_redux(db):
else:
# but we have to do this for sqlite
await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old")
await db.execute(
f"""
await db.execute(f"""
CREATE TABLE lnurlp.pay_links (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
@ -105,8 +100,7 @@ async def m006_redux(db):
webhook_headers TEXT,
webhook_body TEXT
);
"""
)
""")
for row in [
list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old")
@ -172,13 +166,11 @@ async def m009_add_settings(db):
"""
Add extension settings table
"""
await db.execute(
"""
await db.execute("""
CREATE TABLE lnurlp.settings (
nostr_private_key TEXT NOT NULL
);
"""
)
""")
async def m010_add_pay_link_domain(db):
@ -193,14 +185,10 @@ async def m011_add_created_at(db: Connection):
Add created_at to pay links
"""
await db.execute(
f"""ALTER TABLE lnurlp.pay_links ADD COLUMN
created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"""
)
await db.execute(
f"""ALTER TABLE lnurlp.pay_links ADD COLUMN
updated_at TIMESTAMP DEFAULT {db.timestamp_column_default}"""
)
await db.execute(f"""ALTER TABLE lnurlp.pay_links ADD COLUMN
created_at TIMESTAMP DEFAULT {db.timestamp_column_default}""")
await db.execute(f"""ALTER TABLE lnurlp.pay_links ADD COLUMN
updated_at TIMESTAMP DEFAULT {db.timestamp_column_default}""")
now = int(time())
await db.execute(
@ -212,3 +200,9 @@ async def m011_add_created_at(db: Connection):
""",
{"now": now},
)
async def m012_add_disposable(db: Connection):
await db.execute(
"ALTER TABLE lnurlp.pay_links ADD COLUMN disposable BOOLEAN DEFAULT TRUE"
)

111
models.py
View file

@ -1,15 +1,10 @@
import json
from datetime import datetime
from typing import Optional
from datetime import datetime, timezone
from fastapi import Query, Request
from lnbits.helpers import normalize_path
from lnurl import encode as lnurl_encode
from lnurl.types import LnurlPayMetadata
from pydantic import BaseModel
from fastapi import Query
from pydantic import BaseModel, Field
from pynostr.key import PrivateKey
from .helpers import parse_nostr_private_key
from .nostr.key import PrivateKey
class LnurlpSettings(BaseModel):
@ -26,19 +21,21 @@ class LnurlpSettings(BaseModel):
class CreatePayLinkData(BaseModel):
description: str
wallet: Optional[str] = None
wallet: str | None = None
min: float = Query(1, ge=0.01)
max: float = Query(1, ge=0.01)
currency: str = Query(None)
comment_chars: int = Query(0, ge=0, le=799)
webhook_url: str = Query(None)
webhook_headers: str = Query(None)
webhook_body: str = Query(None)
success_text: str = Query(None)
success_url: str = Query(None)
fiat_base_multiplier: int = Query(100, ge=1)
username: str = Query(None)
zaps: Optional[bool] = Query(False)
currency: str | None = Query(None)
webhook_url: str | None = Query(None)
webhook_headers: str | None = Query(None)
webhook_body: str | None = Query(None)
success_text: str | None = Query(None)
success_url: str | None = Query(None)
fiat_base_multiplier: int | None = Query(100, ge=1)
username: str | None = Query(None)
zaps: bool | None = Query(False)
disposable: bool | None = Query(True)
domain: str | None = Query(None)
class PayLink(BaseModel):
@ -46,40 +43,50 @@ class PayLink(BaseModel):
wallet: str
description: str
min: float
max: float
served_meta: int
served_pr: int
username: Optional[str]
zaps: Optional[bool]
domain: Optional[str]
webhook_url: Optional[str]
webhook_headers: Optional[str]
webhook_body: Optional[str]
success_text: Optional[str]
success_url: Optional[str]
currency: Optional[str]
comment_chars: int
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
lnurl: str | None = Field(
default=None,
no_database=True,
deprecated=True,
description=(
"Deprecated: Instead of using this bech32 encoded string, dynamically "
"generate your own static link (lud17/bech32) on the client side. "
"Example: lnurlp://${window.location.hostname}/lnurlp/${paylink_id}"
),
)
username: str | None = None
zaps: bool | None = None
webhook_url: str | None = None
webhook_headers: str | None = None
webhook_body: str | None = None
success_text: str | None = None
success_url: str | None = None
currency: str | None = None
fiat_base_multiplier: int | None = None
disposable: bool
domain: str | None = None
class PublicPayLink(BaseModel):
id: str
username: str | None = None
description: str
min: float
max: float
fiat_base_multiplier: int
created_at: datetime
updated_at: datetime
def lnurl(self, req: Request) -> str:
url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
url = url.replace(path=normalize_path(url.path))
url_str = str(url)
if url.netloc.endswith(".onion"):
# change url string scheme to http
url_str = url_str.replace("https://", "http://")
return lnurl_encode(url_str)
@property
def lnurlpay_metadata(self) -> LnurlPayMetadata:
if self.domain and self.username:
text = f"Payment to {self.username}"
identifier = f"{self.username}@{self.domain}"
metadata = [["text/plain", text], ["text/identifier", identifier]]
else:
metadata = [["text/plain", self.description]]
return LnurlPayMetadata(json.dumps(metadata))
domain: str | None = None
currency: str | None = None
lnurl: str | None = Field(
default=None,
no_database=True,
deprecated=True,
description=(
"Deprecated: Instead of using this bech32 encoded string, dynamically "
"generate your own static link (lud17/bech32) on the client side. "
"Example: lnurlp://${window.location.hostname}/lnurlp/${paylink_id}"
),
)

View file

@ -1,153 +0,0 @@
# Copyright (c) 2017, 2020 Pieter Wuille
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""Reference implementation for Bech32/Bech32m and segwit addresses."""
from enum import Enum
class Encoding(Enum):
"""Enumeration type to list the various supported encodings."""
BECH32 = 1
BECH32M = 2
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
BECH32M_CONST = 0x2BC830A3
def bech32_polymod(values):
"""Internal function that computes the Bech32 checksum."""
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
chk = 1
for value in values:
top = chk >> 25
chk = (chk & 0x1FFFFFF) << 5 ^ value
for i in range(5):
chk ^= generator[i] if ((top >> i) & 1) else 0
return chk
def bech32_hrp_expand(hrp):
"""Expand the HRP into values for checksum computation."""
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
def bech32_verify_checksum(hrp, data):
"""Verify a checksum given HRP and converted data characters."""
const = bech32_polymod(bech32_hrp_expand(hrp) + data)
if const == 1:
return Encoding.BECH32
if const == BECH32M_CONST:
return Encoding.BECH32M
return None
def bech32_create_checksum(hrp, data, spec):
"""Compute the checksum values given HRP and data."""
values = bech32_hrp_expand(hrp) + data
const = BECH32M_CONST if spec == Encoding.BECH32M else 1
polymod = bech32_polymod([*values, 0, 0, 0, 0, 0, 0]) ^ const
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
def bech32_encode(hrp, data, spec):
"""Compute a Bech32 string given HRP and data values."""
combined = data + bech32_create_checksum(hrp, data, spec)
return hrp + "1" + "".join([CHARSET[d] for d in combined])
def bech32_decode(bech):
"""Validate a Bech32/Bech32m string, and determine HRP and data."""
if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or (
bech.lower() != bech and bech.upper() != bech
):
return (None, None, None)
bech = bech.lower()
pos = bech.rfind("1")
if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
return (None, None, None)
if not all(x in CHARSET for x in bech[pos + 1 :]):
return (None, None, None)
hrp = bech[:pos]
data = [CHARSET.find(x) for x in bech[pos + 1 :]]
spec = bech32_verify_checksum(hrp, data)
if spec is None:
return (None, None, None)
return (hrp, data[:-6], spec)
def convertbits(data, frombits, tobits, pad=True):
"""General power-of-2 base conversion."""
acc = 0
bits = 0
ret = []
maxv = (1 << tobits) - 1
max_acc = (1 << (frombits + tobits - 1)) - 1
for value in data:
if value < 0 or (value >> frombits):
return None
acc = ((acc << frombits) | value) & max_acc
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if pad:
if bits:
ret.append((acc << (tobits - bits)) & maxv)
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
return None
return ret
def decode(hrp, addr):
"""Decode a segwit address."""
hrpgot, data, spec = bech32_decode(addr)
assert data, "Invalid bech32 string"
if hrpgot != hrp:
return (None, None)
decoded = convertbits(data[1:], 5, 8, False)
if decoded is None or len(decoded) < 2 or len(decoded) > 40:
return (None, None)
if data[0] > 16:
return (None, None)
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
return (None, None)
if (
data[0] == 0
and spec != Encoding.BECH32
or data[0] != 0
and spec != Encoding.BECH32M
):
return (None, None)
return (data[0], decoded)
def encode(hrp, witver, witprog):
"""Encode a segwit address."""
spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M
bits = convertbits(witprog, 8, 5)
assert bits, "Invalid witness program"
ret = bech32_encode(hrp, [witver, *bits], spec)
if decode(hrp, ret) == (None, None):
return None
return ret

View file

@ -1,133 +0,0 @@
import json
import time
from dataclasses import dataclass, field
from enum import IntEnum
from hashlib import sha256
from typing import List, Optional
from secp256k1 import PublicKey
from .message_type import ClientMessageType
class EventKind(IntEnum):
SET_METADATA = 0
TEXT_NOTE = 1
RECOMMEND_RELAY = 2
CONTACTS = 3
ENCRYPTED_DIRECT_MESSAGE = 4
DELETE = 5
@dataclass
class Event:
content: Optional[str] = None
public_key: Optional[str] = None
created_at: Optional[int] = None
kind: int = EventKind.TEXT_NOTE
tags: List[List[str]] = field(
default_factory=list
) # Dataclasses require special handling when the default value is a mutable type
signature: Optional[str] = None
def __post_init__(self):
if self.content is not None and not isinstance(self.content, str):
# DMs initialize content to None but all other kinds should pass in a str
raise TypeError("Argument 'content' must be of type str")
if self.created_at is None:
self.created_at = int(time.time())
@staticmethod
def serialize(
public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str
) -> bytes:
data = [0, public_key, created_at, kind, tags, content]
data_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
return data_str.encode()
@staticmethod
def compute_id(
public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str
):
return sha256(
Event.serialize(public_key, created_at, kind, tags, content)
).hexdigest()
@property
def id(self) -> str:
# Always recompute the id to reflect the up-to-date state of the Event
assert self.public_key, "Event public key is missing"
assert self.created_at, "Event created_at is missing"
assert self.content is not None, "Event content is missing"
return Event.compute_id(
self.public_key, self.created_at, self.kind, self.tags, self.content
)
def add_pubkey_ref(self, pubkey: str):
"""Adds a reference to a pubkey as a 'p' tag"""
self.tags.append(["p", pubkey])
def add_event_ref(self, event_id: str):
"""Adds a reference to an event_id as an 'e' tag"""
self.tags.append(["e", event_id])
def verify(self) -> bool:
assert self.public_key, "Event public key is missing"
pub_key = PublicKey(
bytes.fromhex("02" + self.public_key), True
) # add 02 for schnorr (bip340)
assert self.signature, "Event signature is missing"
return pub_key.schnorr_verify(
bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True
)
def to_message(self) -> str:
return json.dumps(
[
ClientMessageType.EVENT,
{
"id": self.id,
"pubkey": self.public_key,
"created_at": self.created_at,
"kind": self.kind,
"tags": self.tags,
"content": self.content,
"sig": self.signature,
},
]
)
@dataclass
class EncryptedDirectMessage(Event):
recipient_pubkey: Optional[str] = None
cleartext_content: Optional[str] = None
reference_event_id: Optional[str] = None
def __post_init__(self):
if self.content is not None:
self.cleartext_content = self.content
self.content = None
if self.recipient_pubkey is None:
raise Exception("Must specify a recipient_pubkey.")
self.kind = EventKind.ENCRYPTED_DIRECT_MESSAGE
super().__post_init__()
# Must specify the DM recipient's pubkey in a 'p' tag
self.add_pubkey_ref(self.recipient_pubkey)
# Optionally specify a reference event (DM) this is a reply to
if self.reference_event_id is not None:
self.add_event_ref(self.reference_event_id)
@property
def id(self) -> str:
if self.content is None:
raise Exception(
"EncryptedDirectMessage `id` is undefined until its "
"message is encrypted and stored in the `content` field"
)
return super().id

View file

@ -1,158 +0,0 @@
import base64
import secrets
from typing import Optional
import secp256k1
from cffi import FFI
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from . import bech32
from .event import EncryptedDirectMessage, Event
class PublicKey:
def __init__(self, raw_bytes: bytes) -> None:
self.raw_bytes = raw_bytes
def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_bytes, 8, 5)
return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32)
def hex(self) -> str:
return self.raw_bytes.hex()
def verify_signed_message_hash(self, message_hash: str, sig: str) -> bool:
pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True)
return pk.schnorr_verify(
bytes.fromhex(message_hash), bytes.fromhex(sig), None, True
)
@classmethod
def from_npub(cls, npub: str):
"""Load a PublicKey from its bech32/npub form"""
hrp, data, spec = bech32.bech32_decode(npub)
assert data, "Invalid npub"
bits = bech32.convertbits(data, 5, 8)
assert bits, "Invalid npub"
raw_public_key = bits[:-1]
return cls(bytes(raw_public_key))
class PrivateKey:
def __init__(self, raw_secret: Optional[bytes] = None) -> None:
if raw_secret is not None:
self.raw_secret = raw_secret
else:
self.raw_secret = secrets.token_bytes(32)
sk = secp256k1.PrivateKey(self.raw_secret)
assert sk.pubkey, "Invalid public"
self.public_key = PublicKey(sk.pubkey.serialize()[1:])
@classmethod
def from_nsec(cls, nsec: str):
"""Load a PrivateKey from its bech32/nsec form"""
hrp, data, spec = bech32.bech32_decode(nsec)
bits = bech32.convertbits(data, 5, 8)
assert bits, "Invalid nsec"
raw_secret = bits[:-1]
return cls(bytes(raw_secret))
def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_secret, 8, 5)
return bech32.bech32_encode("nsec", converted_bits, bech32.Encoding.BECH32)
def hex(self) -> str:
return self.raw_secret.hex()
def tweak_add(self, scalar: bytes) -> bytes:
sk = secp256k1.PrivateKey(self.raw_secret)
return sk.tweak_add(scalar)
def compute_shared_secret(self, public_key_hex: str) -> bytes:
pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True)
return pk.ecdh(self.raw_secret, hashfn=copy_x)
def encrypt_message(self, message: str, public_key_hex: str) -> str:
padder = padding.PKCS7(128).padder()
padded_data = padder.update(message.encode()) + padder.finalize()
iv = secrets.token_bytes(16)
cipher = Cipher(
algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)
)
encryptor = cipher.encryptor()
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
msg = base64.b64encode(encrypted_message).decode()
return f"{msg}?iv={base64.b64encode(iv).decode()}"
def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
assert dm.recipient_pubkey, "Recipient public key must be set"
dm.content = self.encrypt_message(
message=dm.cleartext_content or "", public_key_hex=dm.recipient_pubkey
)
def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str:
encoded_data = encoded_message.split("?iv=")
encoded_content, encoded_iv = encoded_data[0], encoded_data[1]
iv = base64.b64decode(encoded_iv)
cipher = Cipher(
algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)
)
encrypted_content = base64.b64decode(encoded_content)
decryptor = cipher.decryptor()
decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize()
return unpadded_data.decode()
def sign_message_hash(self, message_hash: bytes) -> str:
sk = secp256k1.PrivateKey(self.raw_secret)
sig = sk.schnorr_sign(message_hash, None, raw=True)
return sig.hex()
def sign_event(self, event: Event) -> None:
if event.public_key is None:
event.public_key = self.public_key.hex()
event.signature = self.sign_message_hash(bytes.fromhex(event.id))
def __eq__(self, other):
return self.raw_secret == other.raw_secret
def mine_vanity_key(
prefix: Optional[str] = None, suffix: Optional[str] = None
) -> PrivateKey:
if prefix is None and suffix is None:
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")
while True:
sk = PrivateKey()
if (
prefix is not None
and not sk.public_key.bech32()[5 : 5 + len(prefix)] == prefix
):
continue
if suffix is not None and not sk.public_key.bech32()[-len(suffix) :] == suffix:
continue
break
return sk
ffi = FFI()
@ffi.callback(
"int (unsigned char *, const unsigned char *, const unsigned char *, void *)"
)
def copy_x(output, x32, y32, data):
ffi.memmove(output, x32, 32)
return 1

View file

@ -1,20 +0,0 @@
class ClientMessageType:
EVENT = "EVENT"
REQUEST = "REQ"
CLOSE = "CLOSE"
class RelayMessageType:
EVENT = "EVENT"
NOTICE = "NOTICE"
END_OF_STORED_EVENTS = "EOSE"
@staticmethod
def is_valid(relay_type: str) -> bool:
if (
relay_type == RelayMessageType.EVENT
or relay_type == RelayMessageType.NOTICE
or relay_type == RelayMessageType.END_OF_STORED_EVENTS
):
return True
return False

19
package-lock.json generated
View file

@ -9,8 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
"prettier": "^3.7.4",
"pyright": "^1.1.407"
}
},
"node_modules/fsevents": {
@ -18,6 +18,7 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@ -27,9 +28,10 @@
}
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -41,9 +43,10 @@
}
},
"node_modules/pyright": {
"version": "1.1.372",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.372.tgz",
"integrity": "sha512-S0XYmTQWK+ha9FTIWviNk91UnbD569wPUCNEltSqtHeTJhbHj5z3LkOKiqXAOvn72BBfylcgpQqyQHsocmQtiQ==",
"version": "1.1.407",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.407.tgz",
"integrity": "sha512-zU+peTFEVUdokNQyUBhGQYt+NWI/3aiNlvBbDBSsn5Ti334XElFUs+GDjQzCbchYfkT+DvMAT3OkMcV4CuEfDg==",
"license": "MIT",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"

View file

@ -9,7 +9,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
"prettier": "^3.7.4",
"pyright": "^1.1.407"
}
}

3574
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,41 +1,40 @@
[tool.poetry]
[project]
name = "lnbits-lnurlp"
version = "0.0.0"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/lnbits" }
dependencies = ["lnbits>1"]
[tool.poetry]
package-mode = false
[tool.poetry.dependencies]
python = "~3.12 | ~3.11"
lnbits = {version = "1.3.0-rc1", allow-prereleases = true}
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
types-cffi = "^1.16.0.20240331"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[dependency-groups]
dev = [
"black>=24.3.0",
"pytest-asyncio>=0.21.0",
"pytest>=7.3.2",
"mypy==1.17.1",
"pre-commit>=3.2.2",
"ruff>=0.3.2",
"types-cffi>=1.16.0.20240331",
]
[tool.mypy]
# exclude = "(nostr/*)"
plugins = "pydantic.mypy"
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
[[tool.mypy.overrides]]
module = [
"lnbits.*",
"lnurl.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"pyqrcode.*",
"shortuuid.*",
"httpx.*",
"websocket.*",
"secp256k1.*",
"pynostr.*",
]
ignore_missing_imports = "True"

27
static/display.js Normal file
View file

@ -0,0 +1,27 @@
window.PageLnurlpPublic = {
template: '#page-lnurlp-public',
data() {
return {
url: '',
payLink: null
}
},
methods: {
setUrl(link_id, domain) {
this.url = `https://${domain || window.location.host}/lnurlp/${link_id}`
},
getPayLink() {
this.api
.request('GET', `/lnurlp/api/v1/links/public/${this.$route.params.id}`)
.then(res => {
this.payLink = res.data
this.setUrl(this.payLink.id, this.payLink.domain)
})
.catch(this.utils.notifyApiError)
}
},
created() {
this.setUrl(this.$route.params.id)
this.getPayLink()
}
}

67
static/display.vue Normal file
View file

@ -0,0 +1,67 @@
<template id="page-lnurlp-public">
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<div class="text-center">
<lnbits-qrcode-lnurl :url="url" :nfc="true"></lnbits-qrcode-lnurl>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-sm-6 col-md-5 col-lg-4 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mb-sm q-mt-none">
LNbits LNURL-pay link
</h6>
<p class="q-my-none">
Use an LNURL compatible bitcoin wallet to pay.
</p>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-expansion-item
group="extras"
icon="info"
label="Powered by LNURL"
>
<q-card>
<q-card-section>
<p>
LNURL is a range of lightning-network standards that allow
us to use lightning-network differently. An LNURL-pay is a
link that wallets use to fetch an invoice from a server
on-demand. The link or QR code is fixed, but each time it is
read by a compatible wallet a new QR code is issued by the
service. It can be used to activate machines without them
having to maintain an electronic screen to generate and show
invoices locally, or to sell any predefined good or service
automatically.
</p>
<p>
Exploring LNURL and finding use cases, is really helping
inform lightning protocol development, rather than the
protocol dictating how lightning-network should be engaged
with.
</p>
<small
>Check
<a
class="text-secondary"
href="https://github.com/fiatjaf/awesome-lnurl"
target="_blank"
>Awesome LNURL</a
>
for further information.</small
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
</template>

View file

@ -1,32 +1,16 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
const locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
const mapPayLink = obj => {
obj._data = _.clone(obj)
obj.created_at = LNbits.utils.formatDateString(obj.created_at)
obj.updated_at = LNbits.utils.formatDateString(obj.updated_at)
obj.print_url = [locationPath, 'print/', obj.id].join('')
obj.pay_url = [locationPath, 'link/', obj.id].join('')
return obj
}
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
window.PageLnurlp = {
template: '#page-lnurlp',
computed: {
endpoint: function () {
baseUrl() {
return window.location.origin + '/lnurlp/api/v1/links'
},
endpoint() {
return `/lnurlp/api/v1/settings?usr=${this.g.user.id}`
}
},
data() {
return {
activeUrl: '',
settings: [
{
type: 'str',
@ -35,7 +19,6 @@ window.app = Vue.createApp({
}
],
domain: window.location.host,
currencies: [],
fiatRates: {},
payLinks: [],
payLinksTable: {
@ -90,6 +73,7 @@ window.app = Vue.createApp({
show: false,
fixedAmount: true,
data: {
disposable: true,
zaps: false
}
},
@ -100,6 +84,28 @@ window.app = Vue.createApp({
}
},
methods: {
lnaddress(link) {
const domain = link.domain || window.location.host
return `${link.username}@${domain}`
},
mapPayLink(obj) {
const locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
obj._data = _.clone(obj)
obj.created_at = LNbits.utils.formatDate(obj.created_at)
obj.updated_at = LNbits.utils.formatDate(obj.updated_at)
if (obj.currency) {
obj.min = obj.min / obj.fiat_base_multiplier
obj.max = obj.max / obj.fiat_base_multiplier
}
obj.print_url = [locationPath, 'print/', obj.id].join('')
obj.pay_url = [locationPath, 'link/', obj.id].join('')
return obj
},
getPayLinks() {
LNbits.api
.request(
@ -108,11 +114,9 @@ window.app = Vue.createApp({
this.g.user.wallets[0].inkey
)
.then(response => {
this.payLinks = response.data.map(mapPayLink)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
this.payLinks = response.data.map(this.mapPayLink)
})
.catch(LNbits.utils.notifyApiError)
},
closeFormDialog() {
this.resetFormData()
@ -140,17 +144,20 @@ window.app = Vue.createApp({
(link.success_url ? ' and URL "' + link.success_url + '"' : '')
: 'do nothing',
lnurl: link.lnurl,
domain: link.domain,
pay_url: link.pay_url,
print_url: link.print_url,
username: link.username
}
const domain = link.domain || window.location.host
this.activeUrl = `https://${domain}/lnurlp/${link.id}`
this.qrCodeDialog.show = true
},
openUpdateDialog(linkId) {
const link = _.findWhere(this.payLinks, {id: linkId})
if (link.currency) this.updateFiatRate(link.currency)
this.formDialog.data = _.clone(link._data)
this.formDialog.data = {...link}
this.formDialog.show = true
this.formDialog.fixedAmount =
this.formDialog.data.min === this.formDialog.data.max
@ -186,7 +193,7 @@ window.app = Vue.createApp({
)
.then(response => {
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
this.payLinks.push(mapPayLink(response.data))
this.payLinks.push(this.mapPayLink(response.data))
this.formDialog.show = false
this.resetFormData()
})
@ -228,64 +235,18 @@ window.app = Vue.createApp({
},
updateFiatRate(currency) {
LNbits.api
.request('GET', '/lnurlp/api/v1/rate/' + currency, null)
.request('GET', '/api/v1/rate/' + currency, null)
.then(response => {
let rates = _.clone(this.fiatRates)
rates[currency] = response.data.rate
this.fiatRates = rates
this.fiatRates[currency] = response.data.rate
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
writeNfcTag: async function (lnurl) {
try {
if (typeof NDEFReader == 'undefined') {
throw {
toString: function () {
return 'NFC not supported on this device or browser.'
}
}
}
const ndef = new NDEFReader()
this.nfcTagWriting = true
this.$q.notify({
message: 'Tap your NFC tag to write the LNURL-pay link to it.'
})
await ndef.write({
records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}]
})
this.nfcTagWriting = false
this.$q.notify({
type: 'positive',
message: 'NFC tag written successfully.'
})
} catch (error) {
this.nfcTagWriting = false
this.$q.notify({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
}
}
},
created() {
if (this.g.user.wallets?.length) {
this.getPayLinks()
}
LNbits.api
.request('GET', '/lnurlp/api/v1/currencies')
.then(response => {
this.currencies = ['satoshis', ...response.data]
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
})
}

686
static/index.vue Normal file
View file

@ -0,0 +1,686 @@
<template id="page-lnurlp">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New pay link</q-btn
>
<lnbits-extension-settings-btn-dialog
v-if="g.user.admin"
:endpoint="endpoint"
:options="settings"
/>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Pay links</h5>
</div>
</div>
<q-table
dense
flat
:rows="payLinks"
:columns="payLinksTable.columns"
row-key="id"
v-model:pagination="payLinksTable.pagination"
>
<template v-slot:header="props">
<q-tr class="text-left" :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.pay_url"
target="_blank"
class="q-ml-sm"
><q-tooltip>Shareable Page</q-tooltip></q-btn
>
<q-btn
dense
size="xs"
icon="visibility"
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
class="q-ml-sm"
@click="openQrCodeDialog(props.row.id)"
><q-tooltip>View Link</q-tooltip></q-btn
>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
class="q-ml-sm"
>
<q-tooltip>Edit</q-tooltip>
</q-btn>
<q-btn
flat
dense
size="xs"
@click="deletePayLink(props.row.id)"
icon="cancel"
color="pink"
class="q-ml-sm"
><q-tooltip>Delete</q-tooltip></q-btn
>
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.value"
></q-td>
<q-td>
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
<q-tooltip
>Webhook to <span v-text="props.row.webhook_url"></span
></q-tooltip>
</q-icon>
<q-icon
v-if="props.row.success_text || props.row.success_url"
size="14px"
name="call_to_action"
>
<q-tooltip>
On success, show message '<span
v-text="props.row.success_text"
></span
>'
<span v-if="props.row.success_url"
>and URL '<span v-text="props.row.success_url"></span
>'</span
>
</q-tooltip>
</q-icon>
<q-icon
v-if="props.row.comment_chars > 0"
size="14px"
name="insert_comment"
>
<q-tooltip>
<span v-text="props.row.comment_chars"></span>-char
comment allowed
</q-tooltip>
</q-icon>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNURL-pay extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn
flat
label="Swagger API"
type="a"
href="../docs#/lnurlp"
></q-btn>
<q-expansion-item
group="api"
dense
expand-separator
label="List pay links"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/lnurlp/api/v1/links</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;pay_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET <span v-text="baseUrl"></span> -H "X-Api-Key:
<span v-text="g.user.wallets[0].inkey"></span>"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get a pay link"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/lnurlp/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET
<span v-text="baseUrl + '/&lt;pay_id&gt;'"></span>
-H "X-Api-Key:
<span v-text="g.user.wallets[0].inkey"></span>"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create a pay link"
>
<q-btn
flat
label="Swagger API"
type="a"
href="../docs#/lnurlp"
></q-btn>
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/lnurlp/api/v1/links</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<code
>{"description": &lt;string&gt; "amount": &lt;integer&gt;
"max": &lt;integer&gt; "min": &lt;integer&gt;
"comment_chars": &lt;integer&gt; "username":
&lt;string&gt; }</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span> -d
'{"description": &lt;string&gt;, "amount":
&lt;integer&gt;, "max": &lt;integer&gt;, "min":
&lt;integer&gt;, "comment_chars": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key:
<span v-text="g.user.wallets[0].adminkey"></span>"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Update a pay link"
>
<q-card>
<q-card-section>
<code
><span class="text-green">PUT</span>
/lnurlp/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<code
>{"description": &lt;string&gt;, "amount":
&lt;integer&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT
<span v-text="baseUrl + '/&lt;pay_id&gt;'"></span>
-d '{"description": &lt;string&gt;, "amount":
&lt;integer&gt;}' -H "Content-type: application/json" -H
"X-Api-Key:
<span v-text="g.user.wallets[0].adminkey"></span>"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a pay link"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/lnurlp/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 204 NO CONTENT
</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE
<span v-text="baseUrl + '/&lt;pay_id&gt;'"></span>
-H "X-Api-Key:
<span v-text="g.user.wallets[0].adminkey"></span>"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="info"
label="Powered by LNURL"
>
<q-card>
<q-card-section>
<p>
<b>WARNING: LNURL must be used over https or TOR</b><br />
LNURL is a range of lightning-network standards that allow
us to use lightning-network differently. An LNURL-pay is a
link that wallets use to fetch an invoice from a server
on-demand. The link or QR code is fixed, but each time it is
read by a compatible wallet a new QR code is issued by the
service. It can be used to activate machines without them
having to maintain an electronic screen to generate and show
invoices locally, or to sell any predefined good or service
automatically.
</p>
<p>
Exploring LNURL and finding use cases, is really helping
inform lightning protocol development, rather than the
protocol dictating how lightning-network should be engaged
with.
</p>
<small
>Check
<a
class="text-secondary"
href="https://github.com/fiatjaf/awesome-lnurl"
target="_blank"
>Awesome LNURL</a
>
for further information.</small
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.description"
type="text"
label="Item description *"
>
</q-input>
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.trim="formDialog.data.username"
type="text"
label="Lightning Address"
@input="
formDialog.data.username =
formDialog.data.username.toLowerCase()
"
/>
</div>
<div class="col" style="flex: 0 0 auto; margin-top: 10px">
<span class="label"> &nbsp;@&nbsp; </span>
</div>
<div class="col">
<q-input
filled
dense
v-model.trim="formDialog.data.domain"
type="text"
:label="domain"
/>
</div>
</div>
<div class="row q-col-gutter-sm q-mx-sm">
<q-input
filled
dense
v-model.number="formDialog.data.min"
type="number"
:step="
formDialog.data.currency &&
formDialog.data.currency !== 'satoshis'
? '0.01'
: '1'
"
:label="formDialog.fixedAmount ? 'Amount *' : 'Min *'"
:hint="
formDialog.data.currency &&
fiatRates[formDialog.data.currency] &&
formDialog.data.min
? `approx. ${parseInt(Math.round(formDialog.data.min * fiatRates[formDialog.data.currency]))} sat`
: ''
"
></q-input>
<q-input
v-if="!formDialog.fixedAmount"
filled
dense
v-model.number="formDialog.data.max"
type="number"
:step="
formDialog.data.currency &&
formDialog.data.currency !== 'satoshis'
? '0.01'
: '1'
"
label="Max *"
:hint="
formDialog.data.currency &&
fiatRates[formDialog.data.currency] &&
formDialog.data.max
? `approx. ${parseInt(Math.round(formDialog.data.max * fiatRates[formDialog.data.currency]))} sat`
: ''
"
></q-input>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-checkbox
dense
v-model="formDialog.fixedAmount"
label="Fixed amount"
/>
</div>
<div class="col">
<q-select
dense
:options="g.allowedCurrencies || g.currencies"
v-model="formDialog.data.currency"
:display-value="formDialog.data.currency || 'satoshis'"
label="Currency"
:hint="
'Converted to satoshis at each payment. ' +
(formDialog.data.currency &&
fiatRates[formDialog.data.currency]
? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat`
: '')
"
@input="updateFiatRate"
/>
</div>
</div>
<q-expansion-item
group="advanced"
icon="settings"
label="Advanced options"
>
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
LUD-11: Disposable and storeable payRequests.
</h5>
<div class="row">
<div class="col-12">
<q-checkbox
dense
:toggle-indeterminate="false"
v-model="formDialog.data.disposable"
label="If enabled, the LNURL will not be stored (default)."
/>
</div>
</div>
<h5 class="text-caption q-mt-sm q-mb-none">LNURL</h5>
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model.number="formDialog.data.comment_chars"
type="number"
label="Comment maximum characters"
hint="Allow the payer to attach a comment."
>
</q-input>
</div>
</div>
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.webhook_url"
type="text"
label="Webhook URL (optional)"
hint="A URL to be called whenever this link receives a payment."
></q-input>
</div>
</div>
<div class="row" v-if="formDialog.data.webhook_url">
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.webhook_headers"
type="text"
label="Webhook headers (optional)"
hint="Custom data as JSON string, send headers along with the webhook."
></q-input>
</div>
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.webhook_body"
type="text"
label="Webhook custom data (optional)"
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
></q-input>
</div>
</div>
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.success_text"
type="text"
label="Success message (optional)"
hint="Will be shown to the user in his wallet after a successful payment."
></q-input>
</div>
</div>
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.success_url"
type="text"
label="Success URL (optional)"
hint="Link will be shown to the sender after a successful payment."
>
</q-input>
</div>
</div>
</q-card-section>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">Nostr</h5>
<div class="row">
<div class="col-12">
<q-checkbox
:toggle-indeterminate="false"
dense
v-model="formDialog.data.zaps"
label="Enable nostr zaps"
/>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update pay link</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialog.data.wallet == null ||
formDialog.data.description == null ||
formDialog.data.min == null ||
formDialog.data.min <= 0
"
type="submit"
>Create pay link</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<lnbits-qrcode-lnurl :url="activeUrl" :nfc="true"></lnbits-qrcode-lnurl>
<p style="word-break: break-all">
<strong>ID:</strong> <span v-text="qrCodeDialog.data.id"></span><br />
<strong>Amount:</strong>
<span v-text="qrCodeDialog.data.amount"></span><br />
<span v-if="qrCodeDialog.data.currency"
><strong
><span v-text="qrCodeDialog.data.currency"></span> price:</strong
>
<span
v-if="fiatRates[qrCodeDialog.data.currency]"
v-text="fiatRates[qrCodeDialog.data.currency] + 'sat'"
></span>
<span v-else>Loading...</span>
<br
/></span>
<strong>Accepts comments:</strong>
<span v-text="qrCodeDialog.data.comments"></span><br />
<strong>Dispatches webhook to:</strong>
<span v-text="qrCodeDialog.data.webhook"></span><br />
<strong>On success:</strong>
<span v-text="qrCodeDialog.data.success"></span><br />
<span v-if="qrCodeDialog.data.username">
<strong>Lightning Address: </strong>
<span v-text="lnaddress(qrCodeDialog.data)"></span>
<q-icon
name="content_copy"
class="text-grey cursor-pointer q-ml-sm"
@click="utils.copyText(lnaddress(qrCodeDialog.data))"
></q-icon>
<q-icon name="qr_code" class="text-grey cursor-pointer q-ml-sm">
<q-popup-proxy>
<lnbits-qrcode
class="q-pa-md"
:value="lnaddress(qrCodeDialog.data)"
:show-buttons="false"
></lnbits-qrcode>
</q-popup-proxy>
</q-icon>
<br />
</span>
</p>
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
icon="link"
@click="
utils.copyText(
qrCodeDialog.data.pay_url,
'Link copied to clipboard!'
)
"
><q-tooltip>Copy sharable link</q-tooltip>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
</template>

14
static/routes.json Normal file
View file

@ -0,0 +1,14 @@
[
{
"path": "/lnurlp/",
"name": "PageLnurlp",
"template": "/lnurlp/static/index.vue",
"component": "/lnurlp/static/index.js"
},
{
"path": "/lnurlp/link/:id",
"name": "PageLnurlpPublic",
"template": "/lnurlp/static/display.vue",
"component": "/lnurlp/static/display.js"
}
]

View file

@ -1,19 +1,16 @@
import asyncio
import json
import time
from threading import Thread
from typing import List
import httpx
import websockets
from lnbits.core.crud import get_payment, update_payment
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from loguru import logger
from websocket import WebSocketApp
from pynostr.event import Event
from .crud import get_or_create_lnurlp_settings, get_pay_link
from .models import PayLink
from .nostr.event import Event
async def wait_for_paid_invoices():
@ -26,7 +23,6 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment):
if not payment.extra or payment.extra.get("tag") != "lnurlp":
return
@ -85,7 +81,7 @@ async def send_webhook(payment: Payment, pay_link: PayLink, zap_receipt=None):
timeout=6,
)
await mark_webhook_sent(
payment.payment_hash,
payment.checking_id,
r.status_code,
r.is_success,
r.reason_phrase,
@ -94,14 +90,14 @@ async def send_webhook(payment: Payment, pay_link: PayLink, zap_receipt=None):
except Exception as exc:
logger.error(exc)
await mark_webhook_sent(
payment.payment_hash, -1, False, "Unexpected Error", str(exc)
payment.checking_id, -1, False, "Unexpected Error", str(exc)
)
async def mark_webhook_sent(
payment_hash: str, status: int, is_success: bool, reason_phrase="", text=""
checking_id: str, status: int, is_success: bool, reason_phrase="", text=""
) -> None:
payment = await get_payment(payment_hash)
payment = await get_payment(checking_id)
extra = payment.extra or {}
extra["wh_status"] = status # keep for backwards compability
extra["wh_success"] = is_success
@ -124,7 +120,7 @@ async def send_zap(payment: Payment):
return res[0] if res else None
tags = []
for t in ["p", "e"]:
for t in ["p", "e", "a"]:
tag = get_tag(event_json, t)
if tag:
tags.append([t, tag[0]])
@ -140,45 +136,42 @@ async def send_zap(payment: Payment):
)
settings = await get_or_create_lnurlp_settings()
settings.private_key.sign_event(zap_receipt)
zap_receipt.sign(settings.private_key.hex())
def send(relay):
def send_event(_):
logger.debug(f"Sending zap to {ws.url}")
ws.send(zap_receipt.to_message())
time.sleep(2)
ws.close()
async def send_to_relay(relay_url: str, event_message: str):
"""Helper function to send an event to a single relay."""
try:
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}")
ws = WebSocketApp(relay, on_open=send_event)
wst = Thread(target=ws.run_forever, name=f"LNURL zap {relay}")
wst.daemon = True
wst.start()
return ws, wst
# list of all websockets
wss: List[WebSocketApp] = []
# list of all threads for these websockets
wsts: List[Thread] = []
# # send zap via nostrclient
# ws, wst = send(f"wss://localhost:{settings.port}/nostrclient/api/v1/relay")
# wss += [ws]
# wsts += [wst]
# send zap receipt to relays in zap request
# Get relays from the zap request, with a reasonable limit
relays = get_tag(event_json, "relays")
if relays:
if not relays:
return zap_receipt
if len(relays) > 50:
relays = relays[:50]
for r in relays:
ws, wst = send(r)
wss += [ws]
wsts += [wst]
await asyncio.sleep(10)
for ws, wst in zip(wss, wsts):
logger.debug(f"Closing websocket {ws.url}")
ws.close()
wst.join()
# Create a list of tasks to run concurrently
# Run all tasks concurrently. This is a "fire-and-forget" approach.
# We don't need to wait for all of them to complete here.
_ = [
asyncio.create_task(send_to_relay(relay, zap_receipt.to_message()))
for relay in relays
]
return zap_receipt

View file

@ -1,138 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlp"></q-btn>
<q-expansion-item group="api" dense expand-separator label="List pay links">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /lnurlp/api/v1/links</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;pay_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}lnurlp/api/v1/links -H "X-Api-Key:
<span v-text="g.user.wallets[0].inkey"></span> "
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get a pay link">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/lnurlp/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}lnurlp/api/v1/links/&lt;pay_id&gt;
-H "X-Api-Key: <span v-text="g.user.wallets[0].inkey"></span>"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create a pay link"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlp"></q-btn>
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /lnurlp/api/v1/links</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"description": &lt;string&gt; "amount": &lt;integer&gt; "max":
&lt;integer&gt; "min": &lt;integer&gt; "comment_chars":
&lt;integer&gt; "username": &lt;string&gt; }</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}lnurlp/api/v1/links -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;, "max":
&lt;integer&gt;, "min": &lt;integer&gt;, "comment_chars":
&lt;integer&gt;}' -H "Content-type: application/json" -H "X-Api-Key:
<span v-text="g.user.wallets[0].adminkey"></span>"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Update a pay link"
>
<q-card>
<q-card-section>
<code
><span class="text-green">PUT</span>
/lnurlp/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"description": &lt;string&gt;, "amount": &lt;integer&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.base_url }}lnurlp/api/v1/links/&lt;pay_id&gt;
-d '{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key:
<span v-text="g.user.wallets[0].adminkey"></span>"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a pay link"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/lnurlp/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}lnurlp/api/v1/links/&lt;pay_id&gt; -H "X-Api-Key:
<span v-text="g.user.wallets[0].adminkey"></span>"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View file

@ -1,31 +0,0 @@
<q-expansion-item group="extras" icon="info" label="Powered by LNURL">
<q-card>
<q-card-section>
<p>
<b>WARNING: LNURL must be used over https or TOR</b><br />
LNURL is a range of lightning-network standards that allow us to use
lightning-network differently. An LNURL-pay is a link that wallets use
to fetch an invoice from a server on-demand. The link or QR code is
fixed, but each time it is read by a compatible wallet a new QR code is
issued by the service. It can be used to activate machines without them
having to maintain an electronic screen to generate and show invoices
locally, or to sell any predefined good or service automatically.
</p>
<p>
Exploring LNURL and finding use cases, is really helping inform
lightning protocol development, rather than the protocol dictating how
lightning-network should be engaged with.
</p>
<small
>Check
<a
class="text-secondary"
href="https://github.com/fiatjaf/awesome-lnurl"
target="_blank"
>Awesome LNURL</a
>
for further information.</small
>
</q-card-section>
</q-card>
</q-expansion-item>

View file

@ -1,46 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<div class="text-center">
<a class="text-secondary" href="lightning:{{ lnurl }}">
<lnbits-qrcode value="lightning:{{ lnurl }}"></lnbits-qrcode>
</a>
</div>
<div class="row q-mt-lg q-gutter-sm">
<q-btn outline color="grey" @click="copyText('{{ lnurl }}')"
>Copy LNURL</q-btn
>
<q-btn
outline
color="grey"
icon="nfc"
@click="writeNfcTag(' {{ lnurl }} ')"
:disable="nfcTagWriting"
></q-btn>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-sm-6 col-md-5 col-lg-4 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mb-sm q-mt-none">LNbits LNURL-pay link</h6>
<p class="q-my-none">Use an LNURL compatible bitcoin wallet to pay.</p>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "lnurlp/_lnurl.html" %} </q-list>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin]
})
</script>
{% endblock %}

View file

@ -1,430 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New pay link</q-btn
>
<lnbits-extension-settings-btn-dialog
v-if="this.g.user.admin"
:endpoint="endpoint"
:options="settings"
/>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Pay links</h5>
</div>
</div>
<q-table
dense
flat
:rows="payLinks"
:columns="payLinksTable.columns"
row-key="id"
v-model:pagination="payLinksTable.pagination"
>
<template v-slot:header="props">
<q-tr class="text-left" :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
<q-th auto-width></q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.pay_url"
target="_blank"
class="q-ml-sm"
><q-tooltip>Shareable Page</q-tooltip></q-btn
>
<q-btn
unelevated
dense
size="xs"
icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
class="q-ml-sm"
@click="openQrCodeDialog(props.row.id)"
><q-tooltip>View Link</q-tooltip></q-btn
>
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.value"
></q-td>
<q-td>
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
<q-tooltip
>Webhook to <span v-text="props.row.webhook_url"></span
></q-tooltip>
</q-icon>
<q-icon
v-if="props.row.success_text || props.row.success_url"
size="14px"
name="call_to_action"
>
<q-tooltip>
On success, show message '<span
v-text="props.row.success_text"
></span
>'
<span v-if="props.row.success_url"
>and URL '<span v-text="props.row.success_url"></span
>'</span
>
</q-tooltip>
</q-icon>
<q-icon
v-if="props.row.comment_chars > 0"
size="14px"
name="insert_comment"
>
<q-tooltip>
<span v-text="props.row.comment_chars"></span>-char comment
allowed
</q-tooltip>
</q-icon>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip>Edit</q-tooltip>
</q-btn>
<q-btn
flat
dense
size="xs"
@click="deletePayLink(props.row.id)"
icon="cancel"
color="pink"
><q-tooltip>Delete</q-tooltip></q-btn
>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{ SITE_TITLE }} LNURL-pay extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "lnurlp/_api_docs.html" %}
<q-separator></q-separator>
{% include "lnurlp/_lnurl.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.description"
type="text"
label="Item description *"
>
</q-input>
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.trim="formDialog.data.username"
type="text"
label="Lightning Address"
@input="formDialog.data.username = formDialog.data.username.toLowerCase()"
/>
</div>
<div class="col" style="margin-top: 10px">
<span class="label">
&nbsp;@&nbsp;<span v-text="domain"></span>
</span>
</div>
</div>
<div class="row q-col-gutter-sm q-mx-sm">
<q-input
filled
dense
v-model.number="formDialog.data.min"
type="number"
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
:label="formDialog.fixedAmount ? 'Amount *' : 'Min *'"
></q-input>
<q-input
v-if="!formDialog.fixedAmount"
filled
dense
v-model.number="formDialog.data.max"
type="number"
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
label="Max *"
>
</q-input>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-checkbox
dense
v-model="formDialog.fixedAmount"
label="Fixed amount"
/>
</div>
<div class="col">
<q-select
dense
:options="currencies"
v-model="formDialog.data.currency"
:display-value="formDialog.data.currency || 'satoshis'"
label="Currency"
:hint="'Converted to satoshis at each payment. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
@input="updateFiatRate"
/>
</div>
</div>
<q-expansion-item
group="advanced"
icon="settings"
label="Advanced options"
>
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">LNURL</h5>
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model.number="formDialog.data.comment_chars"
type="number"
label="Comment maximum characters"
hint="Allow the payer to attach a comment."
>
</q-input>
</div>
</div>
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.webhook_url"
type="text"
label="Webhook URL (optional)"
hint="A URL to be called whenever this link receives a payment."
></q-input>
</div>
</div>
<div class="row" v-if="formDialog.data.webhook_url">
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.webhook_headers"
type="text"
label="Webhook headers (optional)"
hint="Custom data as JSON string, send headers along with the webhook."
></q-input>
</div>
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.webhook_body"
type="text"
label="Webhook custom data (optional)"
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
></q-input>
</div>
</div>
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.success_text"
type="text"
label="Success message (optional)"
hint="Will be shown to the user in his wallet after a successful payment."
></q-input>
</div>
</div>
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.success_url"
type="text"
label="Success URL (optional)"
hint="Link will be shown to the sender after a successful payment."
>
</q-input>
</div>
</div>
</q-card-section>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">Nostr</h5>
<div class="row">
<div class="col-12">
<q-checkbox
:toggle-indeterminate="false"
dense
v-model="formDialog.data.zaps"
label="Enable nostr zaps"
/>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update pay link</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialog.data.wallet == null ||
formDialog.data.description == null ||
(
formDialog.data.min == null ||
formDialog.data.min <= 0
)
"
type="submit"
>Create pay link</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<lnbits-qrcode
:value="'lightning:' + qrCodeDialog.data.lnurl"
></lnbits-qrcode>
<p style="word-break: break-all">
<strong>ID:</strong> <span v-text="qrCodeDialog.data.id"></span><br />
<strong>Amount:</strong> <span v-text="qrCodeDialog.data.amount"></span
><br />
<span v-if="qrCodeDialog.data.currency"
><strong
><span v-text="qrCodeDialog.data.currency"></span> price:</strong
>
<span
v-if="fiatRates[qrCodeDialog.data.currency]"
v-text="fiatRates[qrCodeDialog.data.currency] + 'sat'"
></span>
<span v-else>Loading...</span>
<br
/></span>
<strong>Accepts comments:</strong>
<span v-text="qrCodeDialog.data.comments"></span><br />
<strong>Dispatches webhook to:</strong>
<span v-text="qrCodeDialog.data.webhook"></span><br />
<strong>On success:</strong>
<span v-text="qrCodeDialog.data.success"></span><br />
<span v-if="qrCodeDialog.data.username">
<strong>Lightning Address: </strong>
<span v-text="qrCodeDialog.data.username+'@'+domain"></span>
<br />
</span>
</p>
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')"
class="q-ml-sm"
>Copy LNURL</q-btn
>
<q-btn
outline
color="grey"
icon="link"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
><q-tooltip>Copy sharable link</q-tooltip>
</q-btn>
<q-btn
outline
color="grey"
icon="nfc"
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
:disable="nfcTagWriting"
><q-tooltip>Write to NFC</q-tooltip>
</q-btn>
<q-btn
outline
color="grey"
icon="print"
type="a"
:href="qrCodeDialog.data.print_url"
target="_blank"
><q-tooltip>Print</q-tooltip></q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/lnurlp/static/js/index.js"></script>
{% endblock %}

View file

@ -1,25 +0,0 @@
{% extends "print.html" %} {% block page %}
<div class="row justify-center">
<div class="qr">
<lnbits-qrcode value="lightning:{{ lnurl }}"></lnbits-qrcode>
</div>
</div>
{% endblock %} {% block styles %}
<style>
.qr {
margin: auto;
}
</style>
{% endblock %} {% block scripts %}
<script>
window.app = Vue.createApp({
el: '#vue',
created() {
window.print()
},
data() {
return {width: window.innerWidth * 0.5}
}
})
</script>
{% endblock %}

151
transport_rpcs.py Normal file
View file

@ -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())

2273
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,45 +1,17 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends, Request
from lnbits.core.models import User
from fastapi import APIRouter, Depends
from lnbits.core.views.generic import index, index_public
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from .crud import get_pay_link
lnurlp_generic_router = APIRouter()
def lnurlp_renderer():
return template_renderer(["lnurlp/templates"])
@lnurlp_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return lnurlp_renderer().TemplateResponse(
"lnurlp/index.html", {"request": request, "user": user.json()}
lnurlp_generic_router.add_api_route(
"/", methods=["GET"], endpoint=index, dependencies=[Depends(check_user_exists)]
)
@lnurlp_generic_router.get("/link/{link_id}", response_class=HTMLResponse)
async def display(request: Request, link_id):
link = await get_pay_link(link_id)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
lnurlp_generic_router.add_api_route(
"/link/{link_id}", methods=["GET"], endpoint=index_public
)
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx)
@lnurlp_generic_router.get("/print/{link_id}", response_class=HTMLResponse)
async def print_qr(request: Request, link_id):
link = await get_pay_link(link_id)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
lnurlp_generic_router.add_api_route(
"/print/{link_id}", methods=["GET"], endpoint=index_public
)
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
return lnurlp_renderer().TemplateResponse("lnurlp/print_qr.html", ctx)

View file

@ -1,19 +1,16 @@
import json
import re
from http import HTTPStatus
from typing import Optional
from fastapi import APIRouter, Depends, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.models import WalletTypeInfo
from lnbits.core.models import SimpleStatus, WalletTypeInfo
from lnbits.decorators import (
check_admin,
require_admin_key,
require_invoice_key,
)
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from starlette.exceptions import HTTPException
from lnurl import InvalidUrl
from .crud import (
create_pay_link,
@ -26,15 +23,28 @@ from .crud import (
update_lnurlp_settings,
update_pay_link,
)
from .helpers import parse_nostr_private_key
from .models import CreatePayLinkData, LnurlpSettings
from .helpers import lnurl_encode_link, parse_nostr_private_key
from .models import CreatePayLinkData, LnurlpSettings, PayLink, PublicPayLink
lnurlp_api_router = APIRouter()
@lnurlp_api_router.get("/api/v1/currencies")
async def api_list_currencies_available():
return list(currencies.keys())
def check_lnurl_encode(req: Request, link: PayLink) -> str:
try:
return lnurl_encode_link(req, link.id, link.domain)
except InvalidUrl as exc:
raise HTTPException(
detail=(
f"Invalid URL for LNURL encoding: `{req.base_url}`. "
"Check proxy settings."
),
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
) from exc
except Exception as exc:
raise HTTPException(
detail="Error encoding LNURL.",
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
) from exc
@lnurlp_api_router.get("/api/v1/links", status_code=HTTPStatus.OK)
@ -42,33 +52,22 @@ async def api_links(
req: Request,
key_info: WalletTypeInfo = Depends(require_invoice_key),
all_wallets: bool = Query(False),
):
) -> list[PayLink]:
wallet_ids = [key_info.wallet.id]
if all_wallets:
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
try:
return [
{**link.dict(), "lnurl": link.lnurl(req)}
for link in await get_pay_links(wallet_ids)
]
except LnurlInvalidUrl as exc:
raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED,
detail=(
"LNURLs need to be delivered over a publicly "
"accessible `https` domain or Tor onion."
),
) from exc
links = await get_pay_links(wallet_ids)
for link in links:
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(
r: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
):
req: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
) -> PayLink:
link = await get_pay_link(link_id)
if not link:
@ -86,7 +85,19 @@ async def api_link_retrieve(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
)
return {**link.dict(), **{"lnurl": link.lnurl(r)}}
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
async def check_username_exists(username: str):
@ -101,19 +112,18 @@ async def check_username_exists(username: str):
@lnurlp_api_router.post("/api/v1/links", status_code=HTTPStatus.CREATED)
@lnurlp_api_router.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_create_or_update(
req: Request,
data: CreatePayLinkData,
request: Request,
link_id: Optional[str] = None,
link_id: str | None = None,
key_info: WalletTypeInfo = Depends(require_admin_key),
):
) -> PayLink:
if data.min > data.max:
raise HTTPException(
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
)
if data.currency is None and (
round(data.min) != data.min or round(data.max) != data.max or data.min < 1
):
if not data.currency:
if round(data.min) != data.min or round(data.max) != data.max or data.min < 1:
raise HTTPException(
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
)
@ -169,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:
@ -198,14 +208,14 @@ async def api_link_create_or_update(
link = await create_pay_link(data)
assert link
return {**link.dict(), "lnurl": link.lnurl(request)}
link.lnurl = check_lnurl_encode(req, link)
return link
@lnurlp_api_router.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_delete(
link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
):
) -> SimpleStatus:
link = await get_pay_link(link_id)
if not link:
@ -213,7 +223,7 @@ async def api_link_delete(
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
)
# admins are allowed to delete paylinks beloging to regular users
# admins are allowed to delete 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 link.wallet != key_info.wallet.id:
@ -222,17 +232,7 @@ async def api_link_delete(
)
await delete_pay_link(link_id)
return {"success": True}
@lnurlp_api_router.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
async def api_check_fiat_rate(currency):
try:
rate = await get_fiat_rate_satoshis(currency)
except AssertionError:
rate = None
return {"rate": rate}
return SimpleStatus(success=True, message="Deleted Pay link")
@lnurlp_api_router.get("/api/v1/settings", dependencies=[Depends(check_admin)])

View file

@ -1,5 +1,5 @@
import json
from http import HTTPStatus
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from lnbits.core.services import create_invoice
@ -9,6 +9,7 @@ from lnurl import (
LightningInvoice,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayMetadata,
LnurlPayResponse,
LnurlPaySuccessActionTag,
Max144Str,
@ -50,7 +51,9 @@ async def api_lnurl_callback(
maximum = link.max
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
if link.currency:
if link.currency and link.fiat_base_multiplier:
link.min = link.min / link.fiat_base_multiplier
link.max = link.max / link.fiat_base_multiplier
# allow some fluctuation (as the fiat price may have changed between the calls)
minimum = rate * 995 * link.min
maximum = rate * 1010 * link.max
@ -61,12 +64,12 @@ async def api_lnurl_callback(
amount = amount
if amount < minimum:
return LnurlErrorResponse(
reason=f"Amount {amount} is smaller than minimum {min}."
reason=f"Amount {amount} is smaller than minimum {minimum}."
)
elif amount > maximum:
return LnurlErrorResponse(
reason=f"Amount {amount} is greater than maximum {max}."
reason=f"Amount {amount} is greater than maximum {maximum}."
)
comment = request.query_params.get("comment")
@ -78,10 +81,6 @@ async def api_lnurl_callback(
)
)
# for lnaddress, we have to set this otherwise
# the metadata won't have the identifier
link.domain = request.url.netloc
extra = {
"tag": "lnurlp",
"link": link.id,
@ -99,11 +98,18 @@ async def api_lnurl_callback(
if nostr:
extra["nostr"] = nostr # put it here for later publishing in tasks.py
if link.username and link.domain:
extra["lnaddress"] = f"{link.username}@{link.domain}"
if link.username:
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
else:
_metadata = [["text/plain", link.description]]
metadata = LnurlPayMetadata(json.dumps(_metadata))
# we take the zap request as the description instead of the metadata if present
unhashed_description = nostr.encode() if nostr else link.lnurlpay_metadata.encode()
unhashed_description = nostr.encode() if nostr else metadata.encode()
payment = await create_invoice(
wallet_id=link.wallet,
@ -119,30 +125,32 @@ async def api_lnurl_callback(
text = link.success_text or f"Link to {link.success_url}"
desc = parse_obj_as(Max144Str, text)
action = UrlAction(tag=LnurlPaySuccessActionTag.url, url=url, description=desc)
return LnurlPayActionResponse(pr=invoice, successAction=action)
return LnurlPayActionResponse(
pr=invoice, successAction=action, disposable=link.disposable
)
if link.success_text:
message = parse_obj_as(Max144Str, link.success_text)
return LnurlPayActionResponse(
pr=invoice, successAction=MessageAction(message=message)
pr=invoice,
successAction=MessageAction(message=message),
disposable=link.disposable,
)
return LnurlPayActionResponse(pr=invoice)
return LnurlPayActionResponse(pr=invoice, disposable=link.disposable)
@lnurlp_lnurl_router.get(
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response.deprecated",
deprecated=True,
)
@lnurlp_lnurl_router.get(
"/{link_id}",
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response",
)
async def api_lnurl_response(
request: Request, link_id: str, webhook_data: Optional[str] = Query(None)
request: Request, link_id: str, webhook_data: str | None = Query(None)
) -> LnurlPayResponse:
link = await get_pay_link(link_id)
if not link:
@ -153,39 +161,48 @@ async def api_lnurl_response(
await update_pay_link(link)
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
if link.currency and link.fiat_base_multiplier:
link.min = link.min / link.fiat_base_multiplier
link.max = link.max / link.fiat_base_multiplier
url = request.url_for("lnurlp.api_lnurl_callback", link_id=link.id)
if webhook_data:
url = url.include_query_params(webhook_data=webhook_data)
link.domain = request.url.netloc
callback_url = parse_obj_as(CallbackUrl, str(url))
if link.username:
identifier = f"{link.username}@{link.domain or request.url.netloc}"
text = f"Payment to {link.username}"
metadata = [["text/plain", text], ["text/identifier", identifier]]
else:
metadata = [["text/plain", link.description]]
res = LnurlPayResponse(
callback=callback_url,
minSendable=MilliSatoshi(round(link.min * rate) * 1000),
maxSendable=MilliSatoshi(round(link.max * rate) * 1000),
metadata=link.lnurlpay_metadata,
# todo library bug should not be in issue to onot specify
payerData=None,
commentAllowed=None,
allowsNostr=None,
nostrPubkey=None,
metadata=LnurlPayMetadata(json.dumps(metadata)),
)
if link.comment_chars > 0:
res.comment_allowed = link.comment_chars
res.commentAllowed = link.comment_chars
if link.zaps:
settings = await get_or_create_lnurlp_settings()
res.allows_nostr = True
res.nostr_pubkey = settings.public_key
res.allowsNostr = True
res.nostrPubkey = settings.public_key
return res
# redirected from /.well-known/lnurlp
@lnurlp_lnurl_router.get("/api/v1/well-known/{username}")
async def lnaddress(username: str, request: Request) -> LnurlPayResponse:
async def lnaddress(
username: str, request: Request
) -> LnurlPayResponse | LnurlErrorResponse:
address_data = await get_address_data(username)
assert address_data, "User not found"
return await api_lnurl_response(request, address_data.id, webhook_data=None)
if not address_data:
return LnurlErrorResponse(reason="Lightning address not found.")
return await api_lnurl_response(request, address_data.id)