Compare commits

...

152 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
dni ⚡
0e0af4d656
chore: update lnurl library (#87)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* chore: update lnurl library
2025-07-17 16:57:29 +02:00
Tiago Vasconcelos
2118c8c745
Add a created at field (#77)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-03-03 19:01:32 +02:00
Vlad Stan
218df89948
fix: normalize url (#76) 2025-03-03 13:34:46 +01:00
João Bordalo
1d66d2f536 fix typo: mininum to minimum 2024-12-04 20:32:02 +01:00
Tiago Vasconcelos
e00a81bfbf
fix: stop the links polling insanity (#72)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
WTF was this for anyway?!
2024-11-25 11:07:26 +01:00
dni ⚡
c7623e4c5a
feat: update to lnbits 1.0.0 (#66)
---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2024-10-25 12:02:37 +02:00
Vlad Stan
3e006654ea
fix: allow empty string content (#69)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-09-14 00:53:04 +03:00
Vlad Stan
98ff395c36
fix: receipt event (#68)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-09-14 00:36:00 +03:00
Tiago Vasconcelos
e3d0fb27e9
chore: code format (#67)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-09-13 20:41:42 +03:00
Tiago Vasconcelos
1d98dd1223 Merge branch 'main' of https://github.com/lnbits/lnurlp 2024-09-10 12:49:53 +01:00
Tiago Vasconcelos
f48a3ed76a Auto stash before rebase of "main" onto "francismars/main" 2024-09-10 12:38:31 +01:00
Vlad Stan
242dd03961
fix: do not return early (#65)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-09-03 22:48:17 +03:00
Vlad Stan
efb2eef323
fix: nostr zaps (#63)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-08-07 17:26:20 +03:00
dni ⚡
5fb5aa382d
fix: parsing url (#62) 2024-08-06 12:29:41 +03:00
Vlad Stan
35dc29822f
fix: do not use assert in http call (#61)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-08-05 17:55:41 +03:00
Vlad Stan
bdef451d89
Admin improvements (#60)
* feat: allow admins to alter pay-links

* chore: `make check`
2024-08-05 13:14:50 +03:00
dni ⚡
badc420069
feat: code quality (#59)
* feat: code quality
* fixup!
2024-08-05 12:49:50 +03:00
Francis
f2669214de
Update tasks.py
Co-authored-by: dni  <office@dnilabs.com>
2024-06-26 13:57:18 +01:00
francismars
f6a7f46e3a added zap_receipt information to webhook 2024-06-26 13:36:27 +01:00
Arc
8aefea7260
Merge pull request #52 from lnbits/advanceddescription
Fixed image urls
2024-05-17 14:24:15 +01:00
benarc
c20cca4425 Fixed image urls 2024-05-17 14:23:32 +01:00
Arc
6ca9971b57
Merge pull request #51 from lnbits/advanceddescription
Added extended description
2024-05-17 14:21:00 +01:00
benarc
b9fee9b93b Added extended description 2024-05-15 22:24:58 +01:00
Tiago Vasconcelos
6f69f67cb8
change extension name (#47)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
Change display name of the extension
2024-04-24 09:57:22 +02:00
Arc
18f230c611
Merge pull request #46 from lnbits/fixup-change-wallet
fix: change wallet for paylink
2024-04-01 09:40:34 +01:00
dni ⚡
9c518b8e6d
fix: change wallet for paylink
closes #26
2024-04-01 10:32:43 +02:00
Arc
c43156b86f
Merge pull request #45 from lnbits/refactor-crud
refactor: do not validate username inside crud
2024-04-01 09:26:50 +02:00
Arc
7194ab60f1
Merge pull request #42 from lnbits/remove-env
chore: remove unused env and new create unique task
2024-04-01 09:26:50 +02:00
dni ⚡
65732f7aa0
chore: remove unused env and new create unique task
remove old scheduled tasks approach

update min version

Update __init__.py

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-04-01 09:26:50 +02:00
dni ⚡
4de7c1a4c0
refactor: do not validate username inside crud
check username on api level not inside crud. adds better error reporting for the api user also in frontend
2024-04-01 09:26:46 +02:00
Arc
0457413bf2
Merge pull request #44 from lnbits/fix-issue-528
fix: issue with empty success url
2024-04-01 07:53:41 +01:00
dni ⚡
f7b4b8d2ff
fix: issue with empty success url
closes #528
2024-03-31 10:19:16 +02:00
ToniValac
a8dc4ac5df increasing LNAddress length limit 2024-02-25 18:48:42 -03:00
dni ⚡
17556ff1b6
fix: lnaddress with query params (#35)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2023-11-24 19:53:39 +01:00
dni ⚡
31158584ae
hotfix4: backwards compat of model_dump 2023-11-24 19:08:04 +01:00
dni ⚡
adb1f3f52b
hotfix3: fix for good
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2023-11-24 18:44:44 +01:00
dni ⚡
9b5a86485e
hotfix2: include_query_params
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2023-11-24 17:56:14 +01:00
dni ⚡
4017706c18
hotfix: lnaddress are broken (#34)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
lnurl validation error.
```
pydantic.error_wrappers.ValidationError: 2 validation errors for LnurlPayResponse
callback
  URL invalid, extra characters found after valid URL: ' extra={}' (type=value_error.url.extra; extra= extra={})
callback
  URL invalid, extra characters found after valid URL: ' extra={}' (type=value_error.url.extra; extra= extra={})
2023-11-24 12:54:05.36 | ERROR | lnbits.app:exception_handler:467 | Exception: 2 validation errors for LnurlPayResponse
callback
  URL invalid, extra characters found after valid URL: ' extra={}' (type=value_error.url.extra; extra= extra={})
callback
  URL invalid, extra characters found after valid URL: ' extra={}' (type=value_error.url.extra; extra= extra={})
2023-11-24 12:54:05.36 | INFO | 52.57.61.135:0 - "GET /lnurlp/api/v1/well-known/test HTTP/1.1" 500
```
2023-11-24 13:15:55 +01:00
dni ⚡
c0017095bf
min version to 0.11.2 (#33)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2023-11-22 11:55:20 +01:00
dni ⚡
84179e8eea
feat: pass data through lnurl for webhook (#31)
* feat: pass through data with lnurl
2023-11-22 11:53:44 +01:00
dni ⚡
f2e419e18d
feat: add extension-settings instead of environs (#28)
* feat: add extension-settings instead of environs
2023-11-22 11:40:22 +01:00
Vlad Stan
257f5d34d2
Fix update validations (#30)
* fix: no `username` update
* fix: update for old pay_links
* chore: code format
2023-11-02 15:09:32 +01:00
dni ⚡
8bad631fb6
bug: update lnurlp without username (#24)
* bug: update lnurlp without username

thrown an exception

```
    if len(kwargs["username"]) > 0:
TypeError: object of type 'NoneType' has no len()
2023-09-29 08:54:04.79 | ERROR | lnbits.app:exception_handler:460 | Exception: object of type 'NoneType' has no len()
```

* nicer if
2023-10-01 09:55:21 +02:00
arbadacarba
ed8118aa1e
Allow only lowercase for lightning-address (#25)
because uppercase fails
2023-10-01 09:54:50 +02:00
dni ⚡
31264e1fe4
fix: latest fastapi (#23)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
str(url) had to be casted earlier
2023-09-29 07:22:45 +02:00
Pavol Rusnak
5706928063
fix typo (#22) 2023-09-26 21:06:26 +02:00
Tiago Vasconcelos
67933b546f
allow custom path (#19)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* allow custom path
* bump

---------

Co-authored-by: dni  <office@dnilabs.com>
2023-09-26 12:27:45 +01:00
Tiago Vasconcelos
049826071c
checks if hostname is .onion and change scheme (#11)
* checks if hostname is .onion and change scheme
* resolve @motorina0 's comments
2023-09-24 20:22:48 +02:00
Benjamin Phạm-Bachelart
7d32a36d51
fix possible duplicate lnaddress when update pay link (#8)
Co-authored-by: dni  <office@dnilabs.com>
2023-09-24 20:21:31 +02:00
Tiago Vasconcelos
ab6b53668e
Fix webhook (#12)
* refactor html
* don't exclude values on update
2023-09-24 20:18:36 +02:00
dni ⚡
a46058134f
[FEAT] add better release workflow (#21) 2023-09-23 22:47:13 +02:00
dni ⚡
3730d51dce
add github release workflow
Some checks failed
release github version / build (push) Has been cancelled
2023-06-28 15:02:09 +02:00
dni ⚡
095c793381
fix openapi issue with exclusiveMaximum (#14)
* fix openapi issue with exclusiveMaximum
* dont  change businesslogic
2023-06-21 09:42:14 +02:00
callebtc
6bce388dcd
Merge pull request #4 from lnbits/zap_support
Zap support
2023-05-30 17:31:22 +02:00
callebtc
d7c0507407 user custom private key 2023-04-27 11:56:41 +02:00
callebtc
b2f970fd66 remove wrong nostrclient include 2023-04-26 13:35:09 +02:00
callebtc
64da75d605 configuration for nostr 2023-04-24 17:12:35 +02:00
Tiago Vasconcelos
6dbb0dfd9a
Update README.md 2023-04-24 10:40:29 +01:00
dni ⚡
47f89afd4e
add fastapi 0.95 combatibility for url_for 2023-04-24 11:22:11 +02:00
callebtc
db607e463e add event signing etc to extension directly 2023-04-04 12:52:34 +02:00
callebtc
bf6659bbbd remove the need for nostrclient 2023-04-04 12:52:10 +02:00
callebtc
642cad9ed8 add max relays 2023-03-25 03:08:54 +01:00
dni ⚡
4022faf778
Merge pull request #3 from arbadacarbaYK/main
Update README.md
2023-03-24 20:28:00 +01:00
callebtc
f755a44108 sleep a bit 2023-03-23 23:46:16 +01:00
callebtc
e119f5c4c5 send zaps to relays in zap request 2023-03-23 23:32:27 +01:00
callebtc
c2e58fa1b4 generate new keys 2023-03-22 17:49:43 +01:00
callebtc
f2615aa155 clean 2023-03-22 09:53:23 +01:00
callebtc
8c5a494489 refactor 2023-03-22 09:53:02 +01:00
callebtc
022a5b79bb refactor 2023-03-22 09:52:47 +01:00
callebtc
b66588d7c9 escape 2023-03-22 01:18:17 +01:00
callebtc
50f9b505cd allowNostr vs alllowsNostr 2023-03-22 00:47:37 +01:00
callebtc
54ca4476cc debug printing 2023-03-21 19:50:32 +01:00
callebtc
0cbee4dc6c I hate you python 2023-03-21 19:37:44 +01:00
callebtc
2e8b6070dc zaaaarpp 2023-03-21 19:22:45 +01:00
callebtc
cdf137b484 description 2023-03-21 19:02:20 +01:00
callebtc
bf22efdd32 fix stuff 2023-03-21 18:27:37 +01:00
callebtc
d8e742a452 ws not wss 2023-03-21 17:48:49 +01:00
callebtc
6fb0a47ad4 unbreak lnurlp 2023-03-21 17:43:11 +01:00
callebtc
fa3cb87ba0 add zaps 2023-03-21 17:39:03 +01:00
arbadacarba
04d1494c90
Update README.md
Moved Darth´ explanations from general info on updating to LNURLp cause it was very specialized to it
2023-03-16 14:16:30 +01:00
callebtc
c51b849fe6 fix hostname 2023-03-15 12:47:24 +01:00
calle
bb69239663
Merge pull request #2 from bitkarrot/lnaddr
Lightning address support
2023-03-15 12:22:23 +01:00
bitkarrot
9b7d96ca3d add spacer for lnaddress 2023-03-14 16:46:11 -07:00
callebtc
5085258bdf Merge branch 'lnaddr' of https://github.com/bitkarrot/lnurlp into lnaddr 2023-03-15 00:45:35 +01:00
callebtc
11a9d02f93 fix missing username 2023-03-15 00:45:22 +01:00
bitkarrot
b672a7710c add ln address to qr code 2023-03-14 16:36:11 -07:00
bitkarrot
096190cfd2 add label for lnaddress to qrcode html 2023-03-14 16:36:11 -07:00
callebtc
8e5ed7d23d show comment extra only when present 2023-03-15 00:26:06 +01:00
bitkarrot
aadebddd82 change LN address display label to username 2023-03-14 16:21:40 -07:00
callebtc
8c2f718c66 add lnaddress to extra 2023-03-15 00:14:25 +01:00
callebtc
e46f1fb027 fix migrations 2023-03-15 00:07:00 +01:00
callebtc
fcd5a30712 fix migrations 2023-03-15 00:06:04 +01:00
callebtc
07a39e6343 clean types 2023-03-14 15:10:57 +01:00
callebtc
bea8db1595 lnaddress works 2023-03-14 15:05:52 +01:00
callebtc
5109833b8f table entry if no ln address is set 2023-03-14 11:52:43 +01:00
callebtc
f2a72a31f1 wording 2023-03-14 11:48:25 +01:00
callebtc
8fbaaeb31b fix table 2023-03-14 11:47:07 +01:00
callebtc
0ae3751cdc username optional in model 2023-03-14 11:40:01 +01:00
Bitkarrot
8ad4d5564b
Update manifest.json
put back org
2023-03-06 23:33:59 -08:00
Bitkarrot
89f9cda6f4
remove trailing slashes for redirect 2023-03-06 23:24:13 -08:00
HackMD
572ab62a02 last changed at Mar 6, 2023 4:49 PM, pushed by Bitkarrot 2023-03-07 00:51:28 +00:00
bitkarrot
8082913eba fix redirect paths 2023-03-05 21:24:54 -08:00
bitkarrot
017cb7353f fix metadata, crud 2023-03-02 23:49:00 -08:00
bitkarrot
58737b58e7 temporary hard link for testing 2023-03-02 23:38:32 -08:00
bitkarrot
afd1cece0b test redirects 2023-03-02 23:23:52 -08:00
bitkarrot
a665714978 add lnurlp to application/json 2023-03-02 22:40:43 -08:00
bitkarrot
e3fafa5c20 adjust lnurl endpoints and calls 2023-03-02 22:30:08 -08:00
bitkarrot
57c36e50aa add lnurl callback methods 2023-03-02 22:25:04 -08:00
bitkarrot
1176fd0322 add redirect paths 2023-03-02 22:10:18 -08:00
bitkarrot
f904c78462 update api docs 2023-03-02 21:19:25 -08:00
bitkarrot
6367dee6c2 add test lnaddress buttton 2023-03-02 21:12:42 -08:00
bitkarrot
abf0305853 fix username display 2023-03-02 20:48:21 -08:00
bitkarrot
9fa895aa6d fix username 2023-03-02 20:34:40 -08:00
Bitkarrot
4a2d41964d
Update manifest.json
try to see if this can import by manifest
2023-03-02 17:47:29 -08:00
bitkarrot
ec091817c7 remove metadata method 2023-03-02 17:10:11 -08:00
bitkarrot
a44f70dcb6 add methods to check dup address and format 2023-03-02 17:03:00 -08:00
bitkarrot
f95814577e update migrations, models, crud 2023-03-02 16:45:41 -08:00
bitkarrot
c476d79580 remove circular, fix html 2023-03-02 16:03:57 -08:00
bitkarrot
577a3932f5 initial crud, index form 2023-03-02 15:51:42 -08:00
bitkarrot
3644e0e254 add to readme 2023-03-02 14:43:26 -08:00
Pavol Rusnak
80aba99673
add license 2023-02-24 18:13:40 +01:00
43 changed files with 4660 additions and 1159 deletions

10
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,10 @@
name: lint
on:
push:
branches:
- main
pull_request:
jobs:
lint:
uses: lnbits/lnbits/.github/workflows/lint.yml@dev

58
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,58 @@
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes
pullrequest:
needs: [release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
token: ${{ secrets.EXT_GITHUB }}
repository: lnbits/lnbits-extensions
path: './lnbits-extensions'
- name: setup git user
run: |
git config --global user.name "alan"
git config --global user.email "alan@lnbits.com"
- name: Create pull request in extensions repo
env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: '${{ github.event.repository.name }}'
tag: '${{ github.ref_name }}'
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
run: |
cd lnbits-extensions
git checkout -b $branch
# if there is another open PR
git pull origin $branch || echo "branch does not exist"
sh util.sh update_extension $repo_name $tag
git add -A
git commit -am "$title"
git push origin $branch
# check if pr exists before creating it
gh config set pager cat
check=$(gh pr list -H $branch | wc -l)
test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__
node_modules
.mypy_cache
.venv

12
.prettierrc Normal file
View file

@ -0,0 +1,12 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"bracketSameLine": false,
"bracketSpacing": false
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 LNbits
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.

47
Makefile Normal file
View file

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

View file

@ -1,6 +1,18 @@
# LNURLp <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>
## Create a static QR code people can use to pay over Lightning Network [![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>
## Create a static QR code or LNaddress people can use to pay over Lightning Network
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 invoice is issued by the service and sent to the wallet. 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 invoice is issued by the service and sent to the wallet.
@ -10,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)\ 1. Create an LNURLp (New Pay link)\
![create lnurlp](https://i.imgur.com/rhUBJFy.jpg) ![create lnurlp](https://i.imgur.com/rhUBJFy.jpg)
- select your wallets - select your wallets
- make a small description - make a small description
- enter amount - enter amount
@ -25,3 +36,39 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n
![LNURLp](https://i.imgur.com/C8s1P0Q.jpg) ![LNURLp](https://i.imgur.com/C8s1P0Q.jpg)
- you can now open your LNURLp and copy the LNURL, get the shareable link or print it\ - 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) ![view lnurlp](https://i.imgur.com/4n41S7T.jpg)
3. Optional - add Lightning Address
- attach a username to your lnurlp to create a lightning address
- the LN address format will be username@lnbits-domain-name
- Find out more about the lightning address spec at lightningaddress.com
## Update your LNURL-pay extension
Now that the extensions are taken out of core LNbits we can update each extension separately without the need to reload or restart LNbits as a whole.
This new version of the extension will give you the option to add a Lightning Address to each LNURLpay link.
- 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 "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)
- Open the LNURLp extension from the left panel
- If you already have had some LNURLp defined, you can now click on edit and add a LN Address to each. _Note that this will change your QR-Code!_
- If you didn't create any LNURLp before nothing changed except the window for defining new ones
[![lnurl-p-ln-address.jpg](https://i.postimg.cc/rsQQc1tr/lnurl-p-ln-address.jpg)](https://postimg.cc/tnnhNVkq)
Now you can receive sats to your newly created LN address. You will find this info also in the transaction overview for each payment (click on the green arrow).
[![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,38 +1,89 @@
import asyncio import asyncio
from typing import List
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database from .crud import db
from lnbits.helpers import template_renderer from .tasks import wait_for_paid_invoices
from lnbits.tasks import catch_everything_and_restart from .views import lnurlp_generic_router
from .views_api import lnurlp_api_router
db = Database("ext_lnurlp") from .views_lnurl import lnurlp_lnurl_router
lnurlp_static_files = [ lnurlp_static_files = [
{ {
"path": "/lnurlp/static", "path": "/lnurlp/static",
"app": StaticFiles(packages=[("lnbits", "extensions/lnurlp/static")]),
"name": "lnurlp_static", "name": "lnurlp_static",
} }
] ]
scheduled_tasks: List[asyncio.Task] = []
lnurlp_redirect_paths = [
{
"from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known",
}
]
lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"]) lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"])
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] = []
def lnurlp_renderer(): def lnurlp_stop():
return template_renderer(["lnbits/extensions/lnurlp/templates"]) for task in scheduled_tasks:
task.cancel()
from .lnurl import * # noqa: F401,F403
from .tasks import wait_for_paid_invoices
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403
def lnurlp_start(): def lnurlp_start():
loop = asyncio.get_event_loop() from lnbits.tasks import create_permanent_unique_task
task = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
task = create_permanent_unique_task("lnurlp", wait_for_paid_invoices)
scheduled_tasks.append(task) scheduled_tasks.append(task)
# Expose lnurlp's CRUD over the LNbits nostr transport so an HTTP-
# allergic client (e.g. lamassu-next ATM) can manage PayLinks over
# kind-21000 encrypted events. Also wires the link-owner resolver so
# `subscribe_payments({tag:"lnurlp", link_id:...})` can verify
# ownership of the underlying wallet. No-op if the core transport
# module isn't present in the LNbits build.
try:
from lnbits.core.services.nostr_transport.dispatcher import (
AUTH_ACCOUNT,
AUTH_WALLET,
register_rpc,
)
from lnbits.core.services.nostr_transport.subscriptions import (
register_link_owner_resolver,
)
except ImportError:
return
from .transport_rpcs import (
handle_lnurlp_create,
handle_lnurlp_delete,
handle_lnurlp_get,
handle_lnurlp_list,
handle_lnurlp_update,
resolve_lnurlp_owner,
)
register_rpc("lnurlp_create", handle_lnurlp_create, AUTH_WALLET)
register_rpc("lnurlp_get", handle_lnurlp_get, AUTH_WALLET)
register_rpc("lnurlp_list", handle_lnurlp_list, AUTH_ACCOUNT)
register_rpc("lnurlp_update", handle_lnurlp_update, AUTH_WALLET)
register_rpc("lnurlp_delete", handle_lnurlp_delete, AUTH_WALLET)
# lnurlp stamps `extra["link"] = link.id` on settlement
# (views_lnurl.py:86), which is the default extras-key, so no override.
register_link_owner_resolver("lnurlp", resolve_lnurlp_owner)
__all__ = [
"db",
"lnurlp_ext",
"lnurlp_redirect_paths",
"lnurlp_start",
"lnurlp_static_files",
"lnurlp_stop",
]

View file

@ -1,10 +1,62 @@
{ {
"name": "LNURLp", "id": "paylink",
"short_description": "Make reusable LNURL pay links", "version": "1.3.0",
"tile": "/lnurlp/static/image/lnurl-pay.png", "name": "Pay Links",
"repo": "https://github.com/lnbits/lnurlp",
"short_description": "Make static reusable LNURL pay links or lightning addresses",
"description": "",
"tile": "/lnurlp/static/image/lnurl-pay.png",
"min_lnbits_version": "1.4.0",
"contributors": [ "contributors": [
"arcbtc", {
"eillarra", "name": "arcbtc",
"fiatjaf" "uri": "https://github.com/arcbtc",
] "role": "Contributor"
},
{
"name": "eillarra",
"uri": "https://github.com/eillarra",
"role": "Contributor"
},
{
"name": "fiatjaf",
"uri": "https://github.com/fiatjaf",
"role": "Contributor"
},
{
"name": "callebtc",
"uri": "https://github.com/callebtc",
"role": "Contributor"
},
{
"name": "dni",
"uri": "https://github.com/dni",
"role": "Contributor"
}
],
"images": [
{
"uri": "https://raw.githubusercontent.com/lnbits/lnurlp/main/static/image/1.jpg",
"link": "https://www.youtube.com/embed/WZpK4xfGcuY?si=_T3gCqKBU8yt6_bD"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/lnurlp/main/static/image/1.png"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/lnurlp/main/static/image/2.png"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/lnurlp/main/static/image/3.png"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/lnurlp/main/static/image/4.png"
}
],
"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",
"paid_features": "",
"tags": ["Merchant", "Payments"],
"donate": "",
"hidden": false
} }

163
crud.py
View file

@ -1,95 +1,108 @@
from typing import List, Optional, Union from datetime import datetime, timezone
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from pynostr.key import PrivateKey
from . import db from .models import CreatePayLinkData, LnurlpSettings, PayLink
from .models import CreatePayLinkData, PayLink
db = Database("ext_lnurlp")
async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: async def get_or_create_lnurlp_settings() -> LnurlpSettings:
settings = await db.fetchone(
"SELECT * FROM lnurlp.settings LIMIT 1", model=LnurlpSettings
)
if settings:
return settings
else:
settings = LnurlpSettings(nostr_private_key=PrivateKey().hex())
await db.insert("lnurlp.settings", settings)
return settings
async def update_lnurlp_settings(settings: LnurlpSettings) -> LnurlpSettings:
await db.update("lnurlp.settings", settings, "")
return settings
async def delete_lnurlp_settings() -> None:
await db.execute("DELETE FROM lnurlp.settings")
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},
PayLink,
)
async def create_pay_link(data: CreatePayLinkData) -> PayLink:
link_id = urlsafe_short_hash()[:6] link_id = urlsafe_short_hash()[:6]
result = await db.execute( assert data.wallet, "Wallet is required"
""" now = datetime.now(timezone.utc)
INSERT INTO lnurlp.pay_links (
id,
wallet,
description,
min,
max,
served_meta,
served_pr,
webhook_url,
webhook_headers,
webhook_body,
success_text,
success_url,
comment_chars,
currency,
fiat_base_multiplier
)
VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
link_id,
wallet_id,
data.description,
data.min,
data.max,
data.webhook_url,
data.webhook_headers,
data.webhook_body,
data.success_text,
data.success_url,
data.comment_chars,
data.currency,
data.fiat_base_multiplier,
),
)
assert result
link = await get_pay_link(link_id) link = PayLink(
assert link, "Newly created link couldn't be retrieved" id=link_id,
wallet=data.wallet,
description=data.description,
min=data.min,
max=data.max,
served_meta=0,
served_pr=0,
username=data.username,
zaps=data.zaps,
webhook_url=data.webhook_url,
webhook_headers=data.webhook_headers,
webhook_body=data.webhook_body,
success_text=data.success_text,
success_url=data.success_url,
currency=data.currency,
comment_chars=data.comment_chars,
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 return link
async def get_pay_link(link_id: str) -> Optional[PayLink]: async def get_address_data(username: str) -> PayLink | None:
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) return await db.fetchone(
return PayLink.from_row(row) if row else None "SELECT * FROM lnurlp.pay_links WHERE username = :username",
{"username": username},
PayLink,
)
async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[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},
PayLink,
)
async def get_pay_links(wallet_ids: str | list[str]) -> list[PayLink]:
if isinstance(wallet_ids, str): if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
q = ",".join(["?"] * len(wallet_ids)) return await db.fetchall(
rows = await db.fetchall( f"SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q}) ORDER BY Id",
f""" model=PayLink,
SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q})
ORDER BY Id
""",
(*wallet_ids,),
) )
return [PayLink.from_row(row) for row in rows]
async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: async def update_pay_link(link: PayLink) -> PayLink:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) link.updated_at = datetime.now(timezone.utc)
await db.execute( await db.update("lnurlp.pay_links", link)
f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) return link
)
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: async def delete_pay_link(link_id: str) -> None:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) await db.execute("DELETE FROM lnurlp.pay_links WHERE id = :id", {"id": link_id})
await db.execute(
f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
)
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
async def delete_pay_link(link_id: int) -> None:
await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,))

10
description.md Normal file
View file

@ -0,0 +1,10 @@
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.

25
helpers.py Normal file
View file

@ -0,0 +1,25 @@
from fastapi import Request
from lnurl import encode as lnurl_encode
from pynostr.key import PrivateKey
def parse_nostr_private_key(key: str) -> PrivateKey:
if key.startswith("nsec"):
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)

106
lnurl.py
View file

@ -1,106 +0,0 @@
from http import HTTPStatus
from fastapi import Request
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
from starlette.exceptions import HTTPException
from lnbits.core.services import create_invoice
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
from . import lnurlp_ext
from .crud import increment_pay_link
@lnurlp_ext.get(
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes (with long URL)
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response.deprecated",
)
@lnurlp_ext.get(
"/{link_id}",
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response",
)
async def api_lnurl_response(request: Request, link_id):
link = await increment_pay_link(link_id, served_meta=1)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
)
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
resp = LnurlPayResponse(
callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id),
min_sendable=round(link.min * rate) * 1000,
max_sendable=round(link.max * rate) * 1000,
metadata=link.lnurlpay_metadata,
)
params = resp.dict()
if link.comment_chars > 0:
params["commentAllowed"] = link.comment_chars
return params
@lnurlp_ext.get(
"/api/v1/lnurl/cb/{link_id}",
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_callback",
)
async def api_lnurl_callback(request: Request, link_id):
link = await increment_pay_link(link_id, served_pr=1)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
)
min, max = link.min, link.max
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
if link.currency:
# allow some fluctuation (as the fiat price may have changed between the calls)
min = rate * 995 * link.min
max = rate * 1010 * link.max
else:
min = link.min * 1000
max = link.max * 1000
amount_received = int(request.query_params.get("amount") or 0)
if amount_received < min:
return LnurlErrorResponse(
reason=f"Amount {amount_received} is smaller than minimum {min}."
).dict()
elif amount_received > max:
return LnurlErrorResponse(
reason=f"Amount {amount_received} is greater than maximum {max}."
).dict()
comment = request.query_params.get("comment")
if len(comment or "") > link.comment_chars:
return LnurlErrorResponse(
reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
).dict()
payment_hash, payment_request = await create_invoice(
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo=link.description,
unhashed_description=link.lnurlpay_metadata.encode(),
extra={
"tag": "lnurlp",
"link": link.id,
"comment": comment,
"extra": request.query_params.get("amount"),
},
)
success_action = link.success_action(payment_hash)
if success_action:
resp = LnurlPayActionResponse(
pr=payment_request, success_action=success_action, routes=[]
)
else:
resp = LnurlPayActionResponse(pr=payment_request, routes=[])
return resp.dict()

View file

@ -1,10 +1,9 @@
{ {
"repos": [ "repos": [
{ {
"id": "lnurlp", "id": "lnurlp",
"organisation": "lnbits", "organisation": "lnbits",
"repository": "lnurlp" "repository": "lnurlp"
} }
] ]
} }

View file

@ -1,9 +1,13 @@
from time import time
from lnbits.db import Connection
async def m001_initial(db): async def m001_initial(db):
""" """
Initial pay table. Initial pay table.
""" """
await db.execute( await db.execute(f"""
f"""
CREATE TABLE lnurlp.pay_links ( CREATE TABLE lnurlp.pay_links (
id {db.serial_primary_key}, id {db.serial_primary_key},
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
@ -12,8 +16,7 @@ async def m001_initial(db):
served_meta INTEGER NOT NULL, served_meta INTEGER NOT NULL,
served_pr INTEGER NOT NULL served_pr INTEGER NOT NULL
); );
""" """)
)
async def m002_webhooks_and_success_actions(db): async def m002_webhooks_and_success_actions(db):
@ -23,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 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_text TEXT;")
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;") await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;")
await db.execute( await db.execute(f"""
f"""
CREATE TABLE lnurlp.invoices ( CREATE TABLE lnurlp.invoices (
pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id), pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id),
payment_hash TEXT NOT NULL, payment_hash TEXT NOT NULL,
webhook_sent INT, -- null means not sent, otherwise store status webhook_sent INT, -- null means not sent, otherwise store status
expiry INT expiry INT
); );
""" """)
)
async def m003_min_max_comment_fiat(db): async def m003_min_max_comment_fiat(db):
@ -58,7 +59,8 @@ async def m004_fiat_base_multiplier(db):
remember to multiply by 100 when we use it to convert to Dollars. remember to multiply by 100 when we use it to convert to Dollars.
""" """
await db.execute( await db.execute(
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;" "ALTER TABLE lnurlp.pay_links "
"ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
) )
@ -80,8 +82,7 @@ async def m006_redux(db):
else: else:
# but we have to do this for sqlite # but we have to do this for sqlite
await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old") await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old")
await db.execute( await db.execute(f"""
f"""
CREATE TABLE lnurlp.pay_links ( CREATE TABLE lnurlp.pay_links (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
@ -99,8 +100,7 @@ async def m006_redux(db):
webhook_headers TEXT, webhook_headers TEXT,
webhook_body TEXT webhook_body TEXT
); );
""" """)
)
for row in [ for row in [
list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old") list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old")
@ -146,3 +146,63 @@ async def m006_redux(db):
) )
await db.execute("DROP TABLE lnurlp.pay_links_old") await db.execute("DROP TABLE lnurlp.pay_links_old")
async def m007_add_lnaddress_username(db):
"""
Add Lightning address to pay links
"""
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN username TEXT;")
async def m008_add_zap_enabled_column(db):
"""
Add Nostr zaps to pay links
"""
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN zaps BOOLEAN;")
async def m009_add_settings(db):
"""
Add extension settings table
"""
await db.execute("""
CREATE TABLE lnurlp.settings (
nostr_private_key TEXT NOT NULL
);
""")
async def m010_add_pay_link_domain(db):
"""
Add domain to pay links
"""
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN domain TEXT;")
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}""")
now = int(time())
await db.execute(
f"""
UPDATE lnurlp.pay_links
SET created_at = {db.timestamp_placeholder('now')},
updated_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL AND updated_at IS NULL
""",
{"now": now},
)
async def m012_add_disposable(db: Connection):
await db.execute(
"ALTER TABLE lnurlp.pay_links ADD COLUMN disposable BOOLEAN DEFAULT TRUE"
)

129
models.py
View file

@ -1,28 +1,41 @@
import json from datetime import datetime, timezone
from sqlite3 import Row
from typing import Dict, Optional
from urllib.parse import ParseResult, urlparse, urlunparse
from fastapi.param_functions import Query from fastapi import Query
from lnurl.types import LnurlPayMetadata from pydantic import BaseModel, Field
from pydantic import BaseModel from pynostr.key import PrivateKey
from starlette.requests import Request
from lnbits.lnurl import encode as lnurl_encode from .helpers import parse_nostr_private_key
class LnurlpSettings(BaseModel):
nostr_private_key: str
@property
def private_key(self) -> PrivateKey:
return parse_nostr_private_key(self.nostr_private_key)
@property
def public_key(self) -> str:
return self.private_key.public_key.hex()
class CreatePayLinkData(BaseModel): class CreatePayLinkData(BaseModel):
description: str description: str
wallet: str | None = None
min: float = Query(1, ge=0.01) min: float = Query(1, ge=0.01)
max: 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)
comment_chars: int = Query(0, ge=0, lt=800) currency: str | None = Query(None)
webhook_url: str = Query(None) webhook_url: str | None = Query(None)
webhook_headers: str = Query(None) webhook_headers: str | None = Query(None)
webhook_body: str = Query(None) webhook_body: str | None = Query(None)
success_text: str = Query(None) success_text: str | None = Query(None)
success_url: str = Query(None) success_url: str | None = Query(None)
fiat_base_multiplier: int = Query(100, ge=1) 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): class PayLink(BaseModel):
@ -30,46 +43,50 @@ class PayLink(BaseModel):
wallet: str wallet: str
description: str description: str
min: float min: float
max: float
served_meta: int served_meta: int
served_pr: int served_pr: int
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 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 max: float
fiat_base_multiplier: int domain: str | None = None
currency: str | None = None
@classmethod lnurl: str | None = Field(
def from_row(cls, row: Row) -> "PayLink": default=None,
data = dict(row) no_database=True,
if data["currency"] and data["fiat_base_multiplier"]: deprecated=True,
data["min"] /= data["fiat_base_multiplier"] description=(
data["max"] /= data["fiat_base_multiplier"] "Deprecated: Instead of using this bech32 encoded string, dynamically "
return cls(**data) "generate your own static link (lud17/bech32) on the client side. "
"Example: lnurlp://${window.location.hostname}/lnurlp/${paylink_id}"
def lnurl(self, req: Request) -> str: ),
url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id) )
return lnurl_encode(url)
@property
def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
def success_action(self, payment_hash: str) -> Optional[Dict]:
if self.success_url:
url: ParseResult = urlparse(self.success_url)
# qs = parse_qs(url.query)
# setattr(qs, "payment_hash", payment_hash)
# url = url._replace(query=urlencode(qs, doseq=True))
return {
"tag": "url",
"description": self.success_text or "~",
"url": urlunparse(url),
}
elif self.success_text:
return {"tag": "message", "message": self.success_text}
else:
return None

62
package-lock.json generated Normal file
View file

@ -0,0 +1,62 @@
{
"name": "lnurlp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lnurlp",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"prettier": "^3.7.4",
"pyright": "^1.1.407"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"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"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prettier": {
"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"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pyright": {
"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"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "lnurlp",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"prettier": "^3.7.4",
"pyright": "^1.1.407"
}
}

97
pyproject.toml Normal file
View file

@ -0,0 +1,97 @@
[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 = [{ 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
[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.*",
"pynostr.*",
]
ignore_missing_imports = "True"
[tool.pytest.ini_options]
log_cli = false
testpaths = [
"tests"
]
[tool.black]
line-length = 88
[tool.ruff]
# Same as Black. + 10% rule of black
line-length = 88
# exclude = [
# "nostr",
# ]
[tool.ruff.lint]
# Enable:
# F - pyflakes
# E - pycodestyle errors
# W - pycodestyle warnings
# I - isort
# A - flake8-builtins
# C - mccabe
# N - naming
# UP - pyupgrade
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
ignore = ["C901"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# needed for pydantic
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
]
# Ignore unused imports in __init__.py files.
# [tool.ruff.lint.extend-per-file-ignores]
# "__init__.py" = ["F401", "F403"]
# [tool.ruff.lint.mccabe]
# max-complexity = 10
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
]

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>

BIN
static/image/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
static/image/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
static/image/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
static/image/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
static/image/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -1,36 +1,69 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ window.PageLnurlp = {
template: '#page-lnurlp',
Vue.component(VueQrcode.name, VueQrcode) computed: {
baseUrl() {
var locationPath = [ return window.location.origin + '/lnurlp/api/v1/links'
window.location.protocol, },
'//', endpoint() {
window.location.host, return `/lnurlp/api/v1/settings?usr=${this.g.user.id}`
window.location.pathname }
].join('') },
var mapPayLink = obj => {
obj._data = _.clone(obj)
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.print_url = [locationPath, 'print/', obj.id].join('')
obj.pay_url = [locationPath, 'link/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data() { data() {
return { return {
currencies: [], activeUrl: '',
settings: [
{
type: 'str',
description: 'Nostr private key used to zap',
name: 'nostr_private_key'
}
],
domain: window.location.host,
fiatRates: {}, fiatRates: {},
checker: null,
payLinks: [], payLinks: [],
payLinksTable: { payLinksTable: {
columns: [
{
name: 'created_at',
label: 'Created',
align: 'left',
field: 'created_at',
sortable: true
},
{
name: 'description',
label: 'Description',
align: 'left',
field: 'description'
},
{
name: 'amount',
label: 'Amount',
align: 'left',
format: (_, row) => {
const min = row.min
const max = row.max
if (min === max) return `${min}`
return `${min} - ${max}`
}
},
{
name: 'currency',
label: 'Currency',
align: 'left',
field: 'currency',
format: val => val ?? 'sat'
},
{
name: 'username',
label: 'Username',
align: 'left',
field: 'username',
sortable: true,
format: val => val ?? 'None',
classes: val => (val ? 'text-normal' : 'text-grey')
}
],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
} }
@ -39,7 +72,10 @@ new Vue({
formDialog: { formDialog: {
show: false, show: false,
fixedAmount: true, fixedAmount: true,
data: {} data: {
disposable: true,
zaps: false
}
}, },
qrCodeDialog: { qrCodeDialog: {
show: false, show: false,
@ -48,6 +84,28 @@ new Vue({
} }
}, },
methods: { 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() { getPayLinks() {
LNbits.api LNbits.api
.request( .request(
@ -56,12 +114,9 @@ new Vue({
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(response => { .then(response => {
this.payLinks = response.data.map(mapPayLink) this.payLinks = response.data.map(this.mapPayLink)
})
.catch(err => {
clearInterval(this.checker)
LNbits.utils.notifyApiError(err)
}) })
.catch(LNbits.utils.notifyApiError)
}, },
closeFormDialog() { closeFormDialog() {
this.resetFormData() this.resetFormData()
@ -89,16 +144,20 @@ new Vue({
(link.success_url ? ' and URL "' + link.success_url + '"' : '') (link.success_url ? ' and URL "' + link.success_url + '"' : '')
: 'do nothing', : 'do nothing',
lnurl: link.lnurl, lnurl: link.lnurl,
domain: link.domain,
pay_url: link.pay_url, pay_url: link.pay_url,
print_url: link.print_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 this.qrCodeDialog.show = true
}, },
openUpdateDialog(linkId) { openUpdateDialog(linkId) {
const link = _.findWhere(this.payLinks, {id: linkId}) const link = _.findWhere(this.payLinks, {id: linkId})
if (link.currency) this.updateFiatRate(link.currency) if (link.currency) this.updateFiatRate(link.currency)
this.formDialog.data = _.clone(link._data) this.formDialog.data = {...link}
this.formDialog.show = true this.formDialog.show = true
this.formDialog.fixedAmount = this.formDialog.fixedAmount =
this.formDialog.data.min === this.formDialog.data.max this.formDialog.data.min === this.formDialog.data.max
@ -107,12 +166,10 @@ new Vue({
const wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
var data = _.omit(this.formDialog.data, 'wallet') const data = _.clone(this.formDialog.data)
if (this.formDialog.fixedAmount) data.max = data.min if (this.formDialog.fixedAmount) data.max = data.min
if (data.currency === 'satoshis') data.currency = null if (data.currency === 'satoshis') data.currency = null
if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0 if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0
if (data.id) { if (data.id) {
this.updatePayLink(wallet, data) this.updatePayLink(wallet, data)
} else { } else {
@ -127,35 +184,16 @@ new Vue({
} }
}, },
updatePayLink(wallet, data) { updatePayLink(wallet, data) {
let values = _.omit(
_.pick(
data,
'description',
'min',
'max',
'webhook_url',
'success_text',
'success_url',
'comment_chars',
'currency'
),
(value, key) =>
(key === 'webhook_url' ||
key === 'success_text' ||
key === 'success_url') &&
(value === null || value === '')
)
LNbits.api LNbits.api
.request( .request(
'PUT', 'PUT',
'/lnurlp/api/v1/links/' + data.id, '/lnurlp/api/v1/links/' + data.id,
wallet.adminkey, wallet.adminkey,
values data
) )
.then(response => { .then(response => {
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id) 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.formDialog.show = false
this.resetFormData() this.resetFormData()
}) })
@ -187,7 +225,7 @@ new Vue({
'/lnurlp/api/v1/links/' + linkId, '/lnurlp/api/v1/links/' + linkId,
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
) )
.then(response => { .then(() => {
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId) this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
}) })
.catch(err => { .catch(err => {
@ -197,68 +235,18 @@ new Vue({
}, },
updateFiatRate(currency) { updateFiatRate(currency) {
LNbits.api LNbits.api
.request('GET', '/lnurlp/api/v1/rate/' + currency, null) .request('GET', '/api/v1/rate/' + currency, null)
.then(response => { .then(response => {
let rates = _.clone(this.fiatRates) this.fiatRates[currency] = response.data.rate
rates[currency] = response.data.rate
this.fiatRates = rates
}) })
.catch(err => { .catch(err => {
LNbits.utils.notifyApiError(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() { created() {
if (this.g.user.wallets.length) { if (this.g.user.wallets?.length) {
var getPayLinks = this.getPayLinks this.getPayLinks()
getPayLinks()
this.checker = setInterval(() => {
getPayLinks()
}, 20000)
} }
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"
}
]

192
tasks.py
View file

@ -2,19 +2,20 @@ import asyncio
import json import json
import httpx import httpx
from loguru import logger import websockets
from lnbits.core.crud import get_payment, update_payment
from lnbits.core.crud import update_payment_extra
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from loguru import logger
from pynostr.event import Event
from .crud import get_pay_link from .crud import get_or_create_lnurlp_settings, get_pay_link
from .models import PayLink
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name()) register_invoice_listener(invoice_queue, "ext_lnurlp")
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()
@ -22,58 +23,155 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment): async def on_invoice_paid(payment: Payment):
if payment.extra.get("tag") != "lnurlp": if not payment.extra or payment.extra.get("tag") != "lnurlp":
return return
if payment.extra.get("wh_status"): if payment.extra.get("wh_status"):
# this webhook has already been sent # this webhook has already been sent
return return
pay_link = await get_pay_link(payment.extra.get("link", -1)) pay_link_id = payment.extra.get("link")
if pay_link and pay_link.webhook_url: if not pay_link_id:
async with httpx.AsyncClient() as client: logger.error("Invoice paid. But no pay link id found.")
try: return
r: httpx.Response = await client.post(
pay_link.webhook_url, pay_link = await get_pay_link(pay_link_id)
json={ if not pay_link:
"payment_hash": payment.payment_hash, logger.error(f"Invoice paid. But Pay link `{pay_link_id}` not found.")
"payment_request": payment.bolt11, return
"amount": payment.amount,
"comment": payment.extra.get("comment"), zap_receipt = None
"lnurlp": pay_link.id, if pay_link.zaps:
"body": json.loads(pay_link.webhook_body) zap_receipt = await send_zap(payment)
await send_webhook(
payment, pay_link, zap_receipt.to_message() if zap_receipt else None
)
async def send_webhook(payment: Payment, pay_link: PayLink, zap_receipt=None):
if not pay_link.webhook_url:
return
async with httpx.AsyncClient() as client:
try:
r: httpx.Response = await client.post(
pay_link.webhook_url,
json={
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": payment.amount,
"comment": payment.extra.get("comment") if payment.extra else None,
"webhook_data": (
payment.extra.get("webhook_data") if payment.extra else None
),
"lnurlp": pay_link.id,
"body": (
json.loads(pay_link.webhook_body)
if pay_link.webhook_body if pay_link.webhook_body
else "", else ""
}, ),
headers=json.loads(pay_link.webhook_headers) "zap_receipt": zap_receipt or "",
},
headers=(
json.loads(pay_link.webhook_headers)
if pay_link.webhook_headers if pay_link.webhook_headers
else None, else None
timeout=40, ),
) timeout=6,
await mark_webhook_sent( )
payment.payment_hash, await mark_webhook_sent(
r.status_code, payment.checking_id,
r.is_success, r.status_code,
r.reason_phrase, r.is_success,
r.text, r.reason_phrase,
) r.text,
except Exception as ex: )
logger.error(ex) except Exception as exc:
await mark_webhook_sent( logger.error(exc)
payment.payment_hash, -1, False, "Unexpected Error", str(ex) await mark_webhook_sent(
) payment.checking_id, -1, False, "Unexpected Error", str(exc)
)
async def mark_webhook_sent( 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: ) -> None:
payment = await get_payment(checking_id)
extra = payment.extra or {}
extra["wh_status"] = status # keep for backwards compability
extra["wh_success"] = is_success
extra["wh_message"] = reason_phrase
extra["wh_response"] = text
payment.extra = extra
await update_payment(payment)
await update_payment_extra(
payment_hash, # NIP-57 - load the zap request
{ async def send_zap(payment: Payment):
"wh_status": status, # keep for backwards compability nostr = payment.extra.get("nostr") if payment.extra else None
"wh_success": is_success, if not nostr:
"wh_message": reason_phrase, return
"wh_response": text,
}, event_json = json.loads(nostr)
def get_tag(event_json, tag):
res = [event_tag[1:] for event_tag in event_json["tags"] if event_tag[0] == tag]
return res[0] if res else None
tags = []
for t in ["p", "e", "a"]:
tag = get_tag(event_json, t)
if tag:
tags.append([t, tag[0]])
tags.append(["bolt11", payment.bolt11])
tags.append(["description", nostr])
pubkey = next((pk[1] for pk in tags if pk[0] == "p"), None)
assert pubkey, "Cannot create zap receipt. Recepient pubkey is missing."
zap_receipt = Event(
kind=9735,
tags=tags,
content="",
) )
settings = await get_or_create_lnurlp_settings()
zap_receipt.sign(settings.private_key.hex())
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}")
# Get relays from the zap request, with a reasonable limit
relays = get_tag(event_json, "relays")
if not relays:
return zap_receipt
if len(relays) > 50:
relays = relays[:50]
# 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:
{{ user.wallets[0].inkey }}"
</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: {{ user.wallets[0].inkey }}"
</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;}</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:
{{ user.wallets[0].adminkey }}"
</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: {{
user.wallets[0].adminkey }}"
</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: {{
user.wallets[0].adminkey }}"
</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,54 +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 }}">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
value="lightning:{{ lnurl }}"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</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>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin]
})
</script>
{% endblock %}

View file

@ -1,345 +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
>
</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
:data="payLinks"
row-key="id"
:pagination.sync="payLinksTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width>Description</q-th>
<q-th auto-width>Amount</q-th>
<q-th auto-width>Currency</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"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)"
></q-btn>
</q-td>
<q-td auto-width>{{ props.row.description }}</q-td>
<q-td auto-width>
<span v-if="props.row.min == props.row.max">
{{ props.row.min }}
</span>
<span v-else>{{ props.row.min }} - {{ props.row.max }}</span>
</q-td>
<q-td>{{ props.row.currency || 'sat' }}</q-td>
<q-td>
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
<q-tooltip>Webhook to {{ props.row.webhook_url}}</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 '{{ props.row.success_text }}'
<span v-if="props.row.success_url"
>and URL '{{ props.row.success_url }}'</span
>
</q-tooltip>
</q-icon>
<q-icon
v-if="props.row.comment_chars > 0"
size="14px"
name="insert_comment"
>
<q-tooltip>
{{ props.row.comment_chars }}-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-btn>
<q-btn
flat
dense
size="xs"
@click="deletePayLink(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</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 q-col-gutter-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="'Amounts will be converted at use-time to satoshis. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
@input="updateFiatRate"
/>
</div>
</div>
<q-input
filled
dense
v-model.number="formDialog.data.comment_chars"
type="number"
label="Comment maximum characters"
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
>
</q-input>
<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>
<q-input
filled
dense
v-if="formDialog.data.webhook_url"
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>
<q-input
filled
dense
v-if="formDialog.data.webhook_url"
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>
<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>
<q-input
filled
dense
v-model="formDialog.data.success_url"
type="text"
label="Success URL (optional)"
hint="Will be shown as a clickable link to the user in his wallet after a successful payment, appended by the payment_hash as a query string."
>
</q-input>
<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">
{% raw %}
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="'lightning:' + qrCodeDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
<span v-if="qrCodeDialog.data.currency"
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
fiatRates[qrCodeDialog.data.currency] ?
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
/></span>
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
}}<br />
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br />
</p>
{% endraw %}
<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,27 +0,0 @@
{% extends "print.html" %} {% block page %}
<div class="row justify-center">
<div class="qr">
<qrcode value="lightning:{{ lnurl }}" :options="{width}"></qrcode>
</div>
</div>
{% endblock %} {% block styles %}
<style>
.qr {
margin: auto;
}
</style>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
created: function () {
window.print()
},
data: function () {
return {width: window.innerWidth * 0.5}
}
})
</script>
{% endblock %}

0
tests/__init__.py Normal file
View file

11
tests/test_init.py Normal file
View file

@ -0,0 +1,11 @@
import pytest
from fastapi import APIRouter
from .. import lnurlp_ext
# just import router and add it to a test router
@pytest.mark.asyncio
async def test_router():
router = APIRouter()
router.include_router(lnurlp_ext)

29
toc.md Normal file
View file

@ -0,0 +1,29 @@
# Terms and Conditions for LNbits Extension
## 1. Acceptance of Terms
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
## 2. License
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
## 3. No Warranty
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
## 4. Limitation of Liability
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
## 5. Modification of Terms
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
## 6. General Provisions
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
## 7. Contact Information
If you have any questions about these Terms, please contact the developer at [developer's contact information].

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,43 +1,17 @@
from http import HTTPStatus from fastapi import APIRouter, Depends
from lnbits.core.views.generic import index, index_public
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from . import lnurlp_ext, lnurlp_renderer lnurlp_generic_router = APIRouter()
from .crud import get_pay_link
templates = Jinja2Templates(directory="templates") lnurlp_generic_router.add_api_route(
"/", methods=["GET"], endpoint=index, dependencies=[Depends(check_user_exists)]
)
lnurlp_generic_router.add_api_route(
"/link/{link_id}", methods=["GET"], endpoint=index_public
)
@lnurlp_ext.get("/", response_class=HTMLResponse) lnurlp_generic_router.add_api_route(
async def index(request: Request, user: User = Depends(check_user_exists)): "/print/{link_id}", methods=["GET"], endpoint=index_public
return lnurlp_renderer().TemplateResponse( )
"lnurlp/index.html", {"request": request, "user": user.dict()}
)
@lnurlp_ext.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."
)
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx)
@lnurlp_ext.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."
)
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
return lnurlp_renderer().TemplateResponse("lnurlp/print_qr.html", ctx)

View file

@ -1,60 +1,73 @@
import json import json
from asyncio.log import logger import re
from http import HTTPStatus from http import HTTPStatus
from fastapi import Depends, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl from lnbits.core.crud import get_user, get_wallet
from starlette.exceptions import HTTPException from lnbits.core.models import SimpleStatus, WalletTypeInfo
from lnbits.decorators import (
check_admin,
require_admin_key,
require_invoice_key,
)
from lnurl import InvalidUrl
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, check_admin, get_key_type
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
from . import lnurlp_ext, scheduled_tasks
from .crud import ( from .crud import (
create_pay_link, create_pay_link,
delete_lnurlp_settings,
delete_pay_link, delete_pay_link,
get_or_create_lnurlp_settings,
get_pay_link, get_pay_link,
get_pay_link_by_username,
get_pay_links, get_pay_links,
update_lnurlp_settings,
update_pay_link, update_pay_link,
) )
from .models import CreatePayLinkData from .helpers import lnurl_encode_link, parse_nostr_private_key
from .models import CreatePayLinkData, LnurlpSettings, PayLink, PublicPayLink
lnurlp_api_router = APIRouter()
@lnurlp_ext.get("/api/v1/currencies") def check_lnurl_encode(req: Request, link: PayLink) -> str:
async def api_list_currencies_available(): try:
return list(currencies.keys()) 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_ext.get("/api/v1/links", status_code=HTTPStatus.OK) @lnurlp_api_router.get("/api/v1/links", status_code=HTTPStatus.OK)
async def api_links( async def api_links(
req: Request, req: Request,
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
): ) -> list[PayLink]:
wallet_ids = [wallet.wallet.id] wallet_ids = [key_info.wallet.id]
if all_wallets: if all_wallets:
user = await get_user(wallet.wallet.user) user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
try: links = await get_pay_links(wallet_ids)
return [ for link in links:
{**link.dict(), "lnurl": link.lnurl(req)} link.lnurl = check_lnurl_encode(req, link)
for link in await get_pay_links(wallet_ids) return links
]
except LnurlInvalidUrl:
raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED,
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
)
@lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @lnurlp_api_router.get("/api/v1/links/{link_id}")
async def api_link_retrieve( async def api_link_retrieve(
r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type) req: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
): ) -> PayLink:
link = await get_pay_link(link_id) link = await get_pay_link(link_id)
if not link: if not link:
@ -62,52 +75,76 @@ async def api_link_retrieve(
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
if link.wallet != wallet.wallet.id: link_wallet = await get_wallet(link.wallet)
# admins are allowed to read paylinks beloging 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 and link_wallet.user != key_info.wallet.user:
raise HTTPException( raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN 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_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) @lnurlp_api_router.get("/api/v1/links/public/{link_id}", response_model=PublicPayLink)
@lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) 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):
prev_link = await get_pay_link_by_username(username)
if prev_link:
raise HTTPException(
detail="Username already taken.",
status_code=HTTPStatus.CONFLICT,
)
@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( async def api_link_create_or_update(
req: Request,
data: CreatePayLinkData, data: CreatePayLinkData,
request: Request, link_id: str | None = None,
link_id=None, key_info: WalletTypeInfo = Depends(require_admin_key),
wallet: WalletTypeInfo = Depends(get_key_type), ) -> PayLink:
):
if data.min > data.max: if data.min > data.max:
raise HTTPException( raise HTTPException(
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
) )
if data.currency is None and ( if not data.currency:
round(data.min) != data.min or round(data.max) != data.max or data.min < 1 if round(data.min) != data.min or round(data.max) != data.max or data.min < 1:
): raise HTTPException(
raise HTTPException( detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST )
)
if data.webhook_headers: if data.webhook_headers:
try: try:
json.loads(data.webhook_headers) json.loads(data.webhook_headers)
except ValueError: except ValueError as exc:
raise HTTPException( raise HTTPException(
detail="Invalid JSON in webhook_headers.", detail="Invalid JSON in webhook_headers.",
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
) ) from exc
if data.webhook_body: if data.webhook_body:
try: try:
json.loads(data.webhook_body) json.loads(data.webhook_body)
except ValueError: except ValueError as exc:
raise HTTPException( raise HTTPException(
detail="Invalid JSON in webhook_body.", detail="Invalid JSON in webhook_body.",
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
) ) from exc
# database only allows int4 entries for min and max. For fiat currencies, # database only allows int4 entries for min and max. For fiat currencies,
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents. # we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
@ -115,12 +152,41 @@ async def api_link_create_or_update(
data.min *= data.fiat_base_multiplier data.min *= data.fiat_base_multiplier
data.max *= data.fiat_base_multiplier data.max *= data.fiat_base_multiplier
if data.success_url is not None and not data.success_url.startswith("https://"): if (
data.success_url
and data.success_url != ""
and not data.success_url.startswith("https://")
):
raise HTTPException( raise HTTPException(
detail="Success URL must be secure https://...", detail="Success URL must be secure https://...",
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
) )
if data.username and not re.match("^[a-z0-9-_.]{1,210}$", data.username):
raise HTTPException(
detail=f"Invalid username: {data.username}. "
"Only letters a-z0-9-_. allowed, min 1 and max 210 characters!",
status_code=HTTPStatus.BAD_REQUEST,
)
# if wallet is not provided, use the wallet of the key
if not data.wallet:
data.wallet = key_info.wallet.id
new_wallet = await get_wallet(data.wallet)
if not new_wallet:
raise HTTPException(
detail="Wallet does not exist.", status_code=HTTPStatus.FORBIDDEN
)
# 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:
raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
)
if link_id: if link_id:
link = await get_pay_link(link_id) link = await get_pay_link(link_id)
@ -129,20 +195,27 @@ async def api_link_create_or_update(
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
if link.wallet != wallet.wallet.id: if data.username and data.username != link.username:
raise HTTPException( await check_username_exists(data.username)
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
)
link = await update_pay_link(**data.dict(), link_id=link_id) for k, v in data.dict().items():
setattr(link, k, v)
link = await update_pay_link(link)
else: else:
link = await create_pay_link(data, wallet_id=wallet.wallet.id) if data.username:
assert link await check_username_exists(data.username)
return {**link.dict(), "lnurl": link.lnurl(request)}
link = await create_pay_link(data)
link.lnurl = check_lnurl_encode(req, link)
return link
@lnurlp_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @lnurlp_api_router.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)): async def api_link_delete(
link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> SimpleStatus:
link = await get_pay_link(link_id) link = await get_pay_link(link_id)
if not link: if not link:
@ -150,31 +223,34 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
if link.wallet != wallet.wallet.id: # 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:
raise HTTPException( raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
) )
await delete_pay_link(link_id) await delete_pay_link(link_id)
return {"success": True} return SimpleStatus(success=True, message="Deleted Pay link")
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK) @lnurlp_api_router.get("/api/v1/settings", dependencies=[Depends(check_admin)])
async def api_check_fiat_rate(currency): async def api_get_or_create_settings() -> LnurlpSettings:
return await get_or_create_lnurlp_settings()
@lnurlp_api_router.put("/api/v1/settings", dependencies=[Depends(check_admin)])
async def api_update_settings(data: LnurlpSettings) -> LnurlpSettings:
try: try:
rate = await get_fiat_rate_satoshis(currency) parse_nostr_private_key(data.nostr_private_key)
except AssertionError: except Exception as exc:
rate = None raise HTTPException(
detail="Invalid Nostr private key.", status_code=HTTPStatus.BAD_REQUEST
return {"rate": rate} ) from exc
return await update_lnurlp_settings(data)
@lnurlp_ext.delete("/api/v1", status_code=HTTPStatus.OK) @lnurlp_api_router.delete("/api/v1/settings", dependencies=[Depends(check_admin)])
async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)): async def api_delete_settings() -> None:
for t in scheduled_tasks: await delete_lnurlp_settings()
try:
t.cancel()
except Exception as ex:
logger.warning(ex)
return {"success": True}

208
views_lnurl.py Normal file
View file

@ -0,0 +1,208 @@
import json
from http import HTTPStatus
from fastapi import APIRouter, HTTPException, Query, Request
from lnbits.core.services import create_invoice
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
from lnurl import (
CallbackUrl,
LightningInvoice,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayMetadata,
LnurlPayResponse,
LnurlPaySuccessActionTag,
Max144Str,
MessageAction,
MilliSatoshi,
UrlAction,
)
from pydantic import parse_obj_as
from .crud import (
get_address_data,
get_or_create_lnurlp_settings,
get_pay_link,
update_pay_link,
)
lnurlp_lnurl_router = APIRouter()
@lnurlp_lnurl_router.get(
"/api/v1/lnurl/cb/{link_id}",
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_callback",
)
async def api_lnurl_callback(
request: Request,
link_id: str,
amount: int = Query(...),
webhook_data: str = Query(None),
) -> LnurlErrorResponse | LnurlPayActionResponse:
link = await get_pay_link(link_id)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
)
link.served_pr = 1
await update_pay_link(link)
minimum = link.min
maximum = link.max
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
# allow some fluctuation (as the fiat price may have changed between the calls)
minimum = rate * 995 * link.min
maximum = rate * 1010 * link.max
else:
minimum = link.min * 1000
maximum = link.max * 1000
amount = amount
if amount < minimum:
return LnurlErrorResponse(
reason=f"Amount {amount} is smaller than minimum {minimum}."
)
elif amount > maximum:
return LnurlErrorResponse(
reason=f"Amount {amount} is greater than maximum {maximum}."
)
comment = request.query_params.get("comment")
if len(comment or "") > link.comment_chars:
return LnurlErrorResponse(
reason=(
f"Got a comment with {len(comment or '')} characters, "
f"but can only accept {link.comment_chars}"
)
)
extra = {
"tag": "lnurlp",
"link": link.id,
"extra": request.query_params.get("amount"),
}
if comment:
extra["comment"] = comment
if webhook_data:
extra["webhook_data"] = webhook_data
# nip 57
nostr = request.query_params.get("nostr")
if nostr:
extra["nostr"] = nostr # put it here for later publishing in tasks.py
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 metadata.encode()
payment = await create_invoice(
wallet_id=link.wallet,
amount=int(amount / 1000),
memo=link.description,
unhashed_description=unhashed_description,
extra=extra,
)
invoice = parse_obj_as(LightningInvoice, LightningInvoice(payment.bolt11))
if link.success_url:
url = parse_obj_as(CallbackUrl, str(link.success_url))
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, disposable=link.disposable
)
if link.success_text:
message = parse_obj_as(Max144Str, link.success_text)
return LnurlPayActionResponse(
pr=invoice,
successAction=MessageAction(message=message),
disposable=link.disposable,
)
return LnurlPayActionResponse(pr=invoice, disposable=link.disposable)
@lnurlp_lnurl_router.get(
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes
name="lnurlp.api_lnurl_response.deprecated",
deprecated=True,
)
@lnurlp_lnurl_router.get(
"/{link_id}",
name="lnurlp.api_lnurl_response",
)
async def api_lnurl_response(
request: Request, link_id: str, webhook_data: str | None = Query(None)
) -> LnurlPayResponse:
link = await get_pay_link(link_id)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
)
link.served_meta = 1
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)
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=LnurlPayMetadata(json.dumps(metadata)),
)
if link.comment_chars > 0:
res.commentAllowed = link.comment_chars
if link.zaps:
settings = await get_or_create_lnurlp_settings()
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 | LnurlErrorResponse:
address_data = await get_address_data(username)
if not address_data:
return LnurlErrorResponse(reason="Lightning address not found.")
return await api_lnurl_response(request, address_data.id)