Compare commits

..

66 commits

Author SHA1 Message Date
9c0e58a87c feat: merge a link's extra into the payout payment (v1.2.2-aio.2)
Some checks failed
lint.yml / feat: merge a link's `extra` into the payout payment (v1.2.2-aio.2) (pull_request) Failing after 0s
lint.yml / feat: merge a link's `extra` into the payout payment (v1.2.2-aio.2) (push) Failing after 0s
Adds an optional `extra` (JSON) field to a withdraw link. When the link
is claimed, that `extra` is merged onto the payout payment's `extra`, so
a caller can tag the resulting payment with metadata an external listener
keys on — the link is the only place to attach it (the customer-facing
LNURL-withdraw payout otherwise carries just `{tag, withdrawal_link_id}`).

Motivating use: bitSpire cash-in settlements. The operator's spirekeeper
listener fires a `cash_in` settlement (fee split to the platform) only on
an outbound payment stamped `source=bitspire`; before this there was no
way to stamp an LNURL-withdraw payout, so cash-ins never settled. bitSpire
now creates the cash-in link for the NET amount with
`extra={source, type:cash_in, principal_sats, fee_sats, ...}` and the
settlement fires on claim.

- models: `extra: dict | None` on CreateWithdrawData + WithdrawLink.
  LNbits' db layer (de)serializes dict columns to/from JSON natively
  (same as Payment.extra) — no per-field validator needed.
- migrations_fork.py: `withdraw_link.extra TEXT` under `withdraw_fork`,
  keeping the upstream-tracked migrations.py byte-identical for clean
  rebases (aiolabs/lnbits#8 pattern).
- views_lnurl: `extra={**(link.extra or {}), "tag": ..., "withdrawal_link_id": ...}`
  — the withdraw extension's own keys are written last so a caller cannot
  clobber them.

Verified end-to-end on the dev stack: a stamped link's payout carries the
merged extra and drives a spirekeeper cash_in settlement + super-fee payout.
2026-06-21 17:26:14 +02:00
2877cf6b20 Revert "fix: allow HTTP LNURL for RFC1918/loopback baseurls (#2)"
Some checks failed
lint.yml / Revert "fix: allow HTTP LNURL for RFC1918/loopback baseurls (#2)" (push) Failing after 0s
This reverts commit 66026ab.

Closes #2 as resolved by switching the dev LNbits to TLS
(self-signed cert) instead of carving out plain HTTP for
RFC1918 hosts. With HTTPS the producer-side python-lnurl
validation accepts any host, AND the lnbits-core consumer-side
`lnurlscan` accepts it too — the symmetric problem the carve-out
couldn't solve on its own.

`create_lnurl_from_baseurl` (#1, `e9d911e`) is kept — it's
orthogonal to the transport scheme and still wanted for the
nostr-transport `lnurl=null` fix.
2026-06-01 21:44:57 +02:00
0e06ab2087 Revert "fix: extend RFC1918 LNURL carve-out to the HTTP-views path"
This reverts commit 40dce41.

Going with TLS termination on the dev LNbits instead, so the
RFC1918 carve-out becomes unnecessary. The lnbits-core
`/api/v1/lnurlscan` consumer-side validator applies the same
HTTPS-required rule python-lnurl enforces; carving the producer
side out only got greg's LNURL generated, not redeemed.
2026-06-01 21:43:37 +02:00
40dce4d88c fix: extend RFC1918 LNURL carve-out to the HTTP-views path
Some checks failed
lint.yml / fix: extend RFC1918 LNURL carve-out to the HTTP-views path (push) Failing after 0s
#2 added the loopback/RFC1918 carve-out to the nostr-transport helper
(`create_lnurl_from_baseurl`) but `views.py` / `views_api.py` still call
`create_lnurl`, which went straight through `lnurl_encode` and got the
same `InvalidUrl` rejection. Visible as a 500 "Error creating LNURL …
check your webserver proxy configuration." on the admin UI when LNbits
itself is on `http://192.168.x.x:port`.

Extract the encode + carve-out logic into `_encode_lnurl(url, hint)` and
route both `create_lnurl` and `create_lnurl_from_baseurl` through it.
Both now return the same `_EncodedLnurl` dataclass (a minimal duck for
`.bech32`/`.url`) — `Lnurl` itself can't be returned in the LAN-local
case because its `__new__` re-runs python-lnurl's host validation on
bech32-decode.

Call sites in views.py / views_api.py unchanged: they already access
`.bech32` and `.url`, which the dataclass exposes. `_populate_lnurl`
back to attribute access too.
2026-06-01 21:35:04 +02:00
66026abe96 fix: allow HTTP LNURL for RFC1918/loopback baseurls (#2)
Some checks failed
lint.yml / fix: allow HTTP LNURL for RFC1918/loopback baseurls (#2) (push) Failing after 0s
`python-lnurl`'s `lnurl_encode` rejects HTTP URLs whose host isn't
`localhost`/`127.0.0.1`/`.onion`, so a regtest LNbits on a LAN IP
(e.g. `http://192.168.0.32:5001`) made `_populate_lnurl` swallow
`InvalidUrl` and leave `link.lnurl=None` — breaking the LAN-local
cross-device smoke flow.

Extend the existing localhost carve-out to the full RFC1918 set:
loopback, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`. These are
intrinsically unreachable from the public internet, so producing an
HTTP LNURL pointing at one is unambiguously a dev/internal scenario.
For matching URLs, skip `lnurl_encode`'s host validation by calling
the public `lnurl.helpers.url_encode` directly (which bech32-encodes
without URL validation). Everything else still goes through the
validated path — production with HTTP + public IP/hostname stays
rejected.

`create_lnurl_from_baseurl` now returns `(bech32, url)` directly
rather than a `Lnurl` instance, since the private-network branch
can't construct a real `Lnurl` (its `__new__` re-runs the same host
validation on bech32-decode). The caller `_populate_lnurl` was the
only consumer.

Test coverage on `_is_private_network_http` covers the carve-out
boundary (loopback, RFC1918, the just-outside-RFC1918 ranges, public
hosts, and the `https://` case). The full encode path is exercised
via regtest smoke.

Closes #2.
2026-06-01 21:14:48 +02:00
e9d911e593 fix: populate lnurl/lnurl_url in nostr-transport handlers (#1)
Some checks failed
lint.yml / fix: populate lnurl/lnurl_url in nostr-transport handlers (#1) (push) Failing after 0s
The HTTP views populate `link.lnurl` and `link.lnurl_url` from
`request.url_for(...)`; the nostr-transport RPC handlers had no
`Request` and so left both fields as `None`. Consumers (ATMs over
nostr) were forced to provision a separate `LNBITS_HTTP_URL` env var
and compose the LNURL callback themselves.

Add `helpers.create_lnurl_from_baseurl(link)` that mirrors
`create_lnurl` but composes the callback URL from
`settings.lnbits_baseurl` instead, and thread it through the
create/get/update/list RPC handlers via a `_populate_lnurl` shim
so the response shape matches the HTTP path. Encoding errors are
swallowed (fields stay `None`) so a misconfigured baseurl falls
back to current behavior rather than failing the RPC.

Closes #1.
2026-06-01 20:01:09 +02:00
82a6d4a894 feat: lnurlw_list_links + lnurlw_unique_hashes transport RPCs
Some checks failed
lint.yml / feat: lnurlw_list_links + lnurlw_unique_hashes transport RPCs (push) Failing after 0s
Two additions surface withdraw-extension capabilities the ATM use
case in aiolabs/lamassu-next (issues #24, #25) needs but couldn't
reach over the nostr transport before:

## lnurlw_list_links (AUTH_ACCOUNT)

Enumerate withdraw links across all wallets owned by the calling
account, with `limit`/`offset` pagination matching the existing
HTTP `/api/v1/links`. Lets an ATM (or any client) re-discover its
links after a reconnect without having to keep its own index.

If `request.wallet_id` is supplied and matches one of the account's
wallets, narrows the listing to just that wallet — mirrors lnurlp's
list semantics.

Returns `{data: [...links], total: <int>}`.

## lnurlw_unique_hashes (AUTH_WALLET)

For an `is_unique=True` link, return the per-use `id_unique_hash`
values derived from each unredeemed slot in `link.usescsv`. Mirrors
the formula in `helpers.py:create_lnurl:13`:

  id_unique_hash = shortuuid.uuid(name=link.id + link.unique_hash + index)

Without this RPC an ATM that wants to generate distinct QR codes
per use (lamassu-next #25) had to reimplement the derivation
client-side — fragile if the extension's hash format ever changes
upstream. With this RPC the ATM asks the server for the canonical
list of unredeemed hashes; each one becomes the trailing path
component of `/withdraw/api/v1/lnurl/<unique_hash>/<id_unique_hash>`.

`is_unique=False` links return an empty `unredeemed_hashes` list;
the base `unique_hash` alone identifies the callback path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:45:40 +02:00
95ed17754d 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
Hooks the existing withdraw CRUD into the LNbits nostr transport layer
so an HTTP-allergic client (e.g. lamassu-next ATM) can manage LNURL-
withdraw links over kind-21000 encrypted events instead of HTTP.

New `withdraw_start()` lifecycle hook (auto-invoked by the LNbits
extension manager) imports the transport's `register_rpc` and registers
four RPCs mirroring the Lightning.Pub `withdraw.*` contract exactly so
lamassu-next's adapter can be a pure name-translation layer:

  lnurlw_create_link   AUTH_WALLET
  lnurlw_get_link      AUTH_WALLET
  lnurlw_update_link   AUTH_WALLET
  lnurlw_delete_link   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 (under tag "withdraw", extras-key "withdrawal_link_id" — the
exact field name views_lnurl.py:144 stamps on payment.extra when a
withdraw settles). That lets clients call
`subscribe_payments({tag:"withdraw", link_id:...})` and stream real-
time claim 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 07:34:25 +02:00
dni ⚡
2e52400f52
fix: enforce check minimum (#72)
Some checks failed
lint / lint (push) Has been cancelled
2026-03-31 09:54:41 +01:00
Tiago Vasconcelos
74852e3494
feat: add disable option for LNURLw (#70) 2026-03-17 21:41:17 +00:00
dni ⚡
ab96594f70
chore: update to 1.2.2
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-12-27 09:48:17 +01:00
PatMulligan
8a20df70fe
FIX: generate LNURL server-side for unique voucher links (#68)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 09:45:57 +01:00
dni ⚡
68ff753cfd
fix: format function for table column (#67) 2025-12-15 07:41:36 +01:00
dni ⚡
eb7f7fda47
chore: update to version 1.2.1 (#66)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-10-06 18:47:56 +02:00
dni ⚡
720aa694c1
fix: revert withdraw to using bech32 lnurl field (#65) 2025-10-06 18:44:49 +02:00
Arc
d0689b7859
fix: timing logic for time between withdraws (#63) 2025-09-15 10:00:40 +02:00
Tiago Vasconcelos
8efacf2d4c
fix: print qr code (#62)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-09-12 14:26:18 +01:00
dni ⚡
10a4caff7e
feat: add lud17 support (#60) 2025-08-25 12:25:20 +02:00
dni ⚡
1bce3bde2d
feat: update to uv (#59) 2025-08-24 23:10:31 +02:00
dni ⚡
717d9c88f8
feat: new lnurl lib and types on endpoints (#57)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-07-21 16:11:10 +02:00
dni ⚡
b42fee99e5
fix: lnurl_encoding error was not handled (#56) 2025-07-15 15:11:17 +02:00
Vlad Stan
6b11dec0cc
[fix] hash check (#54)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-12-11 14:48:15 +02:00
Judy
f05169f994
fix: update select query in get_withdraw_links (#52) 2024-11-29 18:46:47 +02:00
Vlad Stan
432ed5299a
Merge pull request #53 from lnbits/update-v1
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
feat: update to lnbits 1.0.0
2024-11-12 11:25:14 +02:00
Vlad Stan
ce56a00d30 chore: ruff 2024-11-12 11:23:21 +02:00
Vlad Stan
f6aee04c40 fix: lint 2024-11-12 11:00:11 +02:00
Tiago Vasconcelos
d0e5e42398 fix: datetime model 2024-10-29 12:39:47 +00:00
Tiago Vasconcelos
3ca9d35a5d . 2024-10-25 15:18:43 +01:00
Tiago Vasconcelos
c0e85cb0a7 fix: update link 2024-10-25 15:18:13 +01:00
Tiago Vasconcelos
adf5faa6bf fix api_docs 2024-10-25 15:17:09 +01:00
Tiago Vasconcelos
8d731dccfc fix: consistent js 2024-10-25 15:16:50 +01:00
Tiago Vasconcelos
2d0a9f1599 fix QR codes on vouchers 2024-10-25 14:32:53 +01:00
Tiago Vasconcelos
51ea172bc2 fix: migrations 2024-10-25 14:32:40 +01:00
dni ⚡
120e744993
fixup! 2024-10-25 12:07:50 +02:00
dni ⚡
cab62b5c00
rc5 3 2024-10-18 09:52:23 +02:00
dni ⚡
8394e56f5d
rc5 2 2024-10-16 15:52:13 +02:00
dni ⚡
59b3941843
rc5 2024-10-16 11:09:13 +02:00
dni ⚡
9a1cc1b2cd
rc4-1 2024-10-16 11:06:43 +02:00
dni ⚡
d8eafa3e13
fix qrcodes and type ignores 2024-10-07 08:10:58 +02:00
Vlad Stan
1fe26c297f fix: qr code 2024-10-04 13:15:32 +03:00
Vlad Stan
7ea4146d7f fix: v1 changes 2024-10-04 13:12:50 +03:00
dni ⚡
134016312f
fixup! 2024-09-26 10:41:57 +02:00
dni ⚡
2095f86618
feat: update to lnbits 1.0.0 2024-09-13 15:41:27 +02:00
dni ⚡
73e3bab6b3
fix: use timestamp placeholder for postgres compat (#51)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* fix: use timestamp placeholder for postgres compat
---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-09-03 22:13:51 +02:00
dni ⚡
5b1fc0ffbe
chore: bump min version to 0.12.11 (#50)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-09-03 09:57:37 +02:00
Pavol Rusnak
bbe4ef9cf6
fix broken import of MilliSatoshi (#48) 2024-09-03 09:38:45 +02:00
dni ⚡
e2f97f05dd
feat: add created_at time to links (#46)
closes #16
2024-08-30 06:17:26 +02:00
dni ⚡
4e6d61fa01
fix: return 200 status for lnurl errors and fix uniques (#43)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-07-23 15:15:04 +02:00
Julian
00064f65d0
Added paginated fetching to withdraw link table (#41)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-07-19 07:22:46 +02:00
dni ⚡
a44820f61f
feat: add linters and ci (#28)
* feat: introduce linting and ci
* add locks
* prettier
* black and sorting
* f405 missing imports
* E902
* mypy
* renderer
* circular imports
* check comment
* add exports
* add lnurlerrorhandler only on lnurl routes
* add test case
2024-07-11 10:30:28 +02:00
Julian
b5b5abd776
Fix: Add check if no uses left and uses needs to be incremented (#39) 2024-07-10 10:56:24 +02:00
Arc
e034643c7f
Added extended description (#34)
* Adds extended description

* filename fixed

* added video

* Fixed tile link

* Added all contributors
2024-05-17 14:49:10 +01:00
Arc
ee3838ed9f
Adds extended description (#33)
* Adds extended description

* filename fixed

* added video

* Fixed tile link
2024-05-17 14:41:24 +01:00
Vlad Stan
ac87c2fd29 chore: bump min_lnbits_version 2024-05-14 11:04:41 +03:00
Tiago Vasconcelos
b8ffaefa2e
change extension name (#32) 2024-04-24 09:57:02 +02:00
dni ⚡
f350e4cbf0
fix: multiple withdraws not possible when locked (#30)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* fix: multiple withdraws not possible when locked
closes #29
2024-04-19 13:08:35 +02:00
benarc
8128154492 removed unincrement
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-03-21 13:44:30 +00:00
benarc
776946603b Extra logic so lnurl can be tried after failed payment 2024-03-21 13:16:57 +00:00
benarc
ab82ee65f0 added hash check to stop race 2024-03-21 12:37:09 +00:00
benarc
04551571db restored original fix
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-03-20 20:03:12 +00:00
Vlad Stan
a3861204e2
Fix withdraw increment (#26)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* Fix: made increment/is_spent more specific
* fix: make safe `increment_withdraw_link `
* fix: remove old fix
* chore: code format

---------

Co-authored-by: benarc <ben@arc.wales>
2024-03-19 14:19:23 +01:00
Jakub Dvořák
b2b4b40c6e
Add withdrawal_link_id to 'extra' database field for payments (#24) 2024-03-08 09:29:07 +01:00
dni ⚡
2fc4fad757
bug: increment before paying (#23)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
fixes issue in used withdraws
2024-02-06 12:56:29 +01:00
Leonhard Weese
41971f1e08
Update crud.py (#9)
The short url is available for each lnurlw, and with six digits probably far from "safe". I propose to revert to the previous 22 digits.
2024-02-02 21:16:44 +01:00
dni ⚡
ab4c9880be
bug: fix latest fastapi req_for (#20)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2023-09-29 09:11:16 +02:00
Pavol Rusnak
4b87e87f64
fix typo (#19) 2023-09-26 22:03:07 +02:00
40 changed files with 3852 additions and 762 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

View file

@ -1,14 +1,13 @@
on: on:
push: push:
tags: tags:
- "v[0-9]+.[0-9]+.[0-9]+" - 'v[0-9]+.[0-9]+.[0-9]+'
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Create github release - name: Create github release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -20,7 +19,7 @@ jobs:
needs: [release] needs: [release]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with: with:
token: ${{ secrets.EXT_GITHUB }} token: ${{ secrets.EXT_GITHUB }}
repository: lnbits/lnbits-extensions repository: lnbits/lnbits-extensions
@ -34,12 +33,12 @@ jobs:
- name: Create pull request in extensions repo - name: Create pull request in extensions repo
env: env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }} GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}" repo_name: '${{ github.event.repository.name }}'
tag: "${{ github.ref_name }}" tag: '${{ github.ref_name }}'
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}" branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ 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 }}" 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" archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
run: | run: |
cd lnbits-extensions cd lnbits-extensions
git checkout -b $branch git checkout -b $branch

3
.gitignore vendored
View file

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

27
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,27 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-docstring-first
- id: check-json
- id: debug-statements
- id: mixed-line-ending
- id: check-case-conflict
- repo: https://github.com/psf/black
rev: 24.2.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.2
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: 'v4.0.0-alpha.8'
hooks:
- id: prettier
types_or: [css, javascript, html, json]

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
}

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,4 +1,5 @@
# LNURLw - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small> # LNURLw - <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> <small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
## Create a static QR code people can use to withdraw funds from a Lightning Network wallet ## Create a static QR code people can use to withdraw funds from a Lightning Network wallet

View file

@ -1,13 +1,9 @@
from fastapi import APIRouter, Request, Response from fastapi import APIRouter
from fastapi.routing import APIRoute
from fastapi.responses import JSONResponse from .crud import db
from .views import withdraw_ext_generic
from lnbits.db import Database from .views_api import withdraw_ext_api
from lnbits.helpers import template_renderer from .views_lnurl import withdraw_ext_lnurl
from typing import Callable
db = Database("ext_withdraw")
withdraw_static_files = [ withdraw_static_files = [
{ {
@ -16,36 +12,56 @@ withdraw_static_files = [
} }
] ]
class LNURLErrorResponseHandler(APIRoute):
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def custom_route_handler(request: Request) -> Response:
try:
response = await original_route_handler(request)
except HTTPException as exc:
logger.debug(f"HTTPException: {exc}")
response = JSONResponse(
status_code=exc.status_code,
content={"status": "ERROR", "reason": f"{exc.detail}"},
)
except Exception as exc:
raise exc
return response
return custom_route_handler
withdraw_ext: APIRouter = APIRouter(prefix="/withdraw", tags=["withdraw"]) withdraw_ext: APIRouter = APIRouter(prefix="/withdraw", tags=["withdraw"])
withdraw_ext.route_class = LNURLErrorResponseHandler withdraw_ext.include_router(withdraw_ext_generic)
withdraw_ext.include_router(withdraw_ext_api)
withdraw_ext.include_router(withdraw_ext_lnurl)
def withdraw_renderer(): def withdraw_start() -> None:
return template_renderer(["withdraw/templates"]) """
Register this extension's RPCs with the LNbits nostr transport so an
HTTP-allergic client (e.g. lamassu-next ATM) can manage LNURL-withdraw
links without touching the HTTP API. Also wires the link-owner
resolver so subscribe_payments({tag:"withdraw", link_id:...}) can
verify ownership.
No-op if the core transport module isn't present in the LNbits build.
No runtime `if nostr_transport_enabled` guard is needed when
disabled, the relay pool never publishes, so registered RPCs are
simply unreachable.
"""
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_lnurlw_create_link,
handle_lnurlw_delete_link,
handle_lnurlw_get_link,
handle_lnurlw_list_links,
handle_lnurlw_unique_hashes,
handle_lnurlw_update_link,
resolve_withdraw_owner,
)
register_rpc("lnurlw_create_link", handle_lnurlw_create_link, AUTH_WALLET)
register_rpc("lnurlw_get_link", handle_lnurlw_get_link, AUTH_WALLET)
register_rpc("lnurlw_list_links", handle_lnurlw_list_links, AUTH_ACCOUNT)
register_rpc("lnurlw_unique_hashes", handle_lnurlw_unique_hashes, AUTH_WALLET)
register_rpc("lnurlw_update_link", handle_lnurlw_update_link, AUTH_WALLET)
register_rpc("lnurlw_delete_link", handle_lnurlw_delete_link, AUTH_WALLET)
register_link_owner_resolver(
"withdraw", resolve_withdraw_owner, link_extra_key="withdrawal_link_id"
)
from .lnurl import * # noqa: F401,F403 __all__ = ["db", "withdraw_ext", "withdraw_start", "withdraw_static_files"]
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403

View file

@ -1,7 +1,80 @@
{ {
"name": "LNURLw", "name": "Withdraw Links",
"short_description": "Make LNURL withdraw links", "short_description": "Make LNURL withdraw links",
"tile": "/withdraw/static/image/lnurl-withdraw.png", "tile": "/withdraw/static/image/lnurl-withdraw.png",
"contributors": ["arcbtc", "eillarra"], "version": "1.2.2-aio.2",
"min_lnbits_version": "0.11.0" "min_lnbits_version": "1.3.0",
"contributors": [
{
"name": "arcbtc",
"uri": "https://github.com/arcbtc",
"role": "Developer"
},
{
"name": "talvasconcelos",
"uri": "https://github.com/talvasconcelos",
"role": "Developer"
},
{
"name": "eillarra",
"uri": "https://github.com/eillarra",
"role": "Developer"
},
{
"name": "dni",
"uri": "https://github.com/dni",
"role": "Developer"
},
{
"name": "motorina0",
"uri": "https://github.com/motorina0",
"role": "Developer"
},
{
"name": "prusnak",
"uri": "https://github.com/prusnak",
"role": "Developer"
},
{
"name": "callebtc",
"uri": "https://github.com/callebtc",
"role": "Developer"
},
{
"name": "Liongrass",
"uri": "https://github.com/Liongrass",
"role": "Developer"
},
{
"name": "supiiik",
"uri": "https://github.com/supiiik",
"role": "Developer"
},
{
"name": "Jakub-Dv",
"uri": "https://github.com/Jakub-Dv",
"role": "Developer"
}
],
"images": [
{
"uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/1.jpg",
"link": "https://www.youtube.com/embed/TUmsHpJtveQ?si=3_l1cg0JC8CXHtYf"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/1.png"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/2.png"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/3.png"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/4.png"
}
],
"description_md": "https://raw.githubusercontent.com/lnbits/withdraw/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/withdraw/lnurldevice/main/toc.md",
"license": "MIT"
} }

225
crud.py
View file

@ -1,100 +1,100 @@
from datetime import datetime from datetime import datetime
from typing import List, Optional, Union
import shortuuid import shortuuid
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from . import db from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
from .models import CreateWithdrawData, HashCheck, WithdrawLink
db = Database("ext_withdraw")
async def create_withdraw_link( async def create_withdraw_link(
data: CreateWithdrawData, wallet_id: str data: CreateWithdrawData, wallet_id: str
) -> WithdrawLink: ) -> WithdrawLink:
link_id = urlsafe_short_hash()[:6] link_id = urlsafe_short_hash()[:22]
available_links = ",".join([str(i) for i in range(data.uses)]) available_links = ",".join([str(i) for i in range(data.uses)])
await db.execute( withdraw_link = WithdrawLink(
""" id=link_id,
INSERT INTO withdraw.withdraw_link ( wallet=wallet_id,
id, unique_hash=urlsafe_short_hash(),
wallet, k1=urlsafe_short_hash(),
title, created_at=datetime.now(),
min_withdrawable, open_time=int(datetime.now().timestamp()) + data.wait_time,
max_withdrawable, title=data.title,
uses, min_withdrawable=data.min_withdrawable,
wait_time, max_withdrawable=data.max_withdrawable,
is_unique, uses=data.uses,
unique_hash, wait_time=data.wait_time,
k1, is_unique=data.is_unique,
open_time, usescsv=available_links,
usescsv, webhook_url=data.webhook_url,
webhook_url, webhook_headers=data.webhook_headers,
webhook_headers, webhook_body=data.webhook_body,
webhook_body, custom_url=data.custom_url,
custom_url extra=data.extra,
) number=0,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
link_id,
wallet_id,
data.title,
data.min_withdrawable,
data.max_withdrawable,
data.uses,
data.wait_time,
int(data.is_unique),
urlsafe_short_hash(),
urlsafe_short_hash(),
int(datetime.now().timestamp()) + data.wait_time,
available_links,
data.webhook_url,
data.webhook_headers,
data.webhook_body,
data.custom_url,
),
) )
link = await get_withdraw_link(link_id, 0) await db.insert("withdraw.withdraw_link", withdraw_link)
assert link, "Newly created link couldn't be retrieved" return withdraw_link
async def get_withdraw_link(link_id: str, num=0) -> WithdrawLink | None:
link = await db.fetchone(
"SELECT * FROM withdraw.withdraw_link WHERE id = :id",
{"id": link_id},
WithdrawLink,
)
if not link:
return None
link.number = num
return link return link
async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]: async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> WithdrawLink | None:
row = await db.fetchone( link = await db.fetchone(
"SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,) "SELECT * FROM withdraw.withdraw_link WHERE unique_hash = :hash",
{"hash": unique_hash},
WithdrawLink,
) )
if not row: if not link:
return None return None
link = dict(**row) link.number = num
link["number"] = num return link
return WithdrawLink.parse_obj(link)
async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]: async def get_withdraw_links(
row = await db.fetchone( wallet_ids: list[str], limit: int, offset: int
"SELECT * FROM withdraw.withdraw_link WHERE unique_hash = ?", (unique_hash,) ) -> PaginatedWithdraws:
q = ",".join([f"'{w}'" for w in wallet_ids])
query_str = f"""
SELECT * FROM withdraw.withdraw_link WHERE wallet IN ({q})
ORDER BY open_time DESC
"""
if limit > 0:
query_str += """ LIMIT :limit OFFSET :offset"""
query_params = {"limit": limit, "offset": offset}
else:
query_params = {}
links = await db.fetchall(
query_str,
query_params,
WithdrawLink,
) )
if not row: result = await db.execute(
return None f"""
SELECT COUNT(*) as total FROM withdraw.withdraw_link
link = dict(**row) WHERE wallet IN ({q})
link["number"] = num """
return WithdrawLink.parse_obj(link)
async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[WithdrawLink]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM withdraw.withdraw_link WHERE wallet IN ({q}) ORDER BY open_time DESC", (*wallet_ids,)
) )
return [WithdrawLink(**row) for row in rows] result2 = result.mappings().first()
return PaginatedWithdraws(data=links, total=int(result2.total))
async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None: async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None:
@ -103,36 +103,25 @@ async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> N
for x in link.usescsv.split(",") for x in link.usescsv.split(",")
if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip()) if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
] ]
await update_withdraw_link( link.usescsv = ",".join(unique_links)
link.id, await update_withdraw_link(link)
usescsv=",".join(unique_links),
)
async def increment_withdraw_link(link: WithdrawLink) -> None: async def increment_withdraw_link(link: WithdrawLink) -> None:
await update_withdraw_link( link.used = link.used + 1
link.id, link.open_time = int(datetime.now().timestamp())
used=link.used + 1, await update_withdraw_link(link)
open_time=link.wait_time + int(datetime.now().timestamp()),
)
async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]: async def update_withdraw_link(link: WithdrawLink) -> WithdrawLink:
if "is_unique" in kwargs: await db.update("withdraw.withdraw_link", link)
kwargs["is_unique"] = int(kwargs["is_unique"]) return link
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE withdraw.withdraw_link SET {q} WHERE id = ?",
(*kwargs.values(), link_id),
)
row = await db.fetchone(
"SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,)
)
return WithdrawLink(**row) if row else None
async def delete_withdraw_link(link_id: str) -> None: async def delete_withdraw_link(link_id: str) -> None:
await db.execute("DELETE FROM withdraw.withdraw_link WHERE id = ?", (link_id,)) await db.execute(
"DELETE FROM withdraw.withdraw_link WHERE id = :id", {"id": link_id}
)
def chunks(lst, n): def chunks(lst, n):
@ -143,31 +132,45 @@ def chunks(lst, n):
async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
await db.execute( await db.execute(
""" """
INSERT INTO withdraw.hash_check ( INSERT INTO withdraw.hash_check (id, lnurl_id)
id, VALUES (:id, :lnurl_id)
lnurl_id
)
VALUES (?, ?)
""", """,
(the_hash, lnurl_id), {"id": the_hash, "lnurl_id": lnurl_id},
) )
hashCheck = await get_hash_check(the_hash, lnurl_id) hash_check = await get_hash_check(the_hash, lnurl_id)
return hashCheck return hash_check
async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
rowid = await db.fetchone(
"SELECT * FROM withdraw.hash_check WHERE id = ?", (the_hash,) hash_check = await db.fetchone(
"""
SELECT id as hash, lnurl_id as lnurl
FROM withdraw.hash_check WHERE id = :id
""",
{"id": the_hash},
HashCheck,
) )
rowlnurl = await db.fetchone( hash_check_lnurl = await db.fetchone(
"SELECT * FROM withdraw.hash_check WHERE lnurl_id = ?", (lnurl_id,) """
SELECT id as hash, lnurl_id as lnurl
FROM withdraw.hash_check WHERE lnurl_id = :id
""",
{"id": lnurl_id},
HashCheck,
) )
if not rowlnurl: if not hash_check_lnurl:
await create_hash_check(the_hash, lnurl_id) await create_hash_check(the_hash, lnurl_id)
return HashCheck(lnurl=True, hash=False) return HashCheck(lnurl=True, hash=False)
else: else:
if not rowid: if not hash_check:
await create_hash_check(the_hash, lnurl_id) await create_hash_check(the_hash, lnurl_id)
return HashCheck(lnurl=True, hash=False) return HashCheck(lnurl=True, hash=False)
else: else:
return HashCheck(lnurl=True, hash=True) return HashCheck(lnurl=True, hash=True)
async def delete_hash_check(the_hash: str) -> None:
await db.execute(
"DELETE FROM withdraw.hash_check WHERE id = :hash", {"hash": the_hash}
)

7
description.md Normal file
View file

@ -0,0 +1,7 @@
Create a static QR code people can use to withdraw funds from a Lightning Network wallet
LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL withdraw is the permission for someone to pull a certain amount of funds from a lightning wallet.
The most common use case for an LNURL withdraw is a faucet, although it is a very powerful technology, with much further reaching implications. For example, an LNURL withdraw could be minted to pay for a subscription service. Or you can have a LNURLw as an offline Lightning wallet (a pre paid "card"), you use to pay for something without having to even reach your smartphone.
LNURL withdraw is a very powerful tool and should not have his use limited to just faucet applications. With LNURL withdraw, you have the ability to give someone the right to spend a range, once or multiple times. This functionality has not existed in money before.

54
helpers.py Normal file
View file

@ -0,0 +1,54 @@
from fastapi import Request
from lnbits.settings import settings
from lnurl import Lnurl
from lnurl import encode as lnurl_encode
from shortuuid import uuid
from .models import WithdrawLink
def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
if link.is_unique:
usescssv = link.usescsv.split(",")
tohash = link.id + link.unique_hash + usescssv[link.number]
multihash = uuid(name=tohash)
url = req.url_for(
"withdraw.api_lnurl_multi_response",
unique_hash=link.unique_hash,
id_unique_hash=multihash,
)
else:
url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash)
try:
return lnurl_encode(str(url))
except Exception as e:
raise ValueError(
f"Error creating LNURL with url: `{url!s}`, "
"check your webserver proxy configuration."
) from e
def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl:
"""
Same shape as `create_lnurl`, but composes the callback URL from
`settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by
the nostr-transport RPC handlers, which have no HTTP request to
derive a base URL from.
"""
base = settings.lnbits_baseurl.rstrip("/")
if link.is_unique:
usescssv = link.usescsv.split(",")
tohash = link.id + link.unique_hash + usescssv[link.number]
multihash = uuid(name=tohash)
url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}/{multihash}"
else:
url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}"
try:
return lnurl_encode(url)
except Exception as e:
raise ValueError(
f"Error creating LNURL with url: `{url!s}`, "
"check your `LNBITS_BASEURL` configuration."
) from e

212
lnurl.py
View file

@ -1,212 +0,0 @@
import json
from datetime import datetime
from http import HTTPStatus
from urllib.parse import urlparse
import httpx
import shortuuid
from fastapi import HTTPException, Query, Request
from fastapi.responses import JSONResponse
from loguru import logger
from lnbits.core.crud import update_payment_extra
from lnbits.core.services import pay_invoice
from . import withdraw_ext
from .crud import (
get_withdraw_link_by_hash,
increment_withdraw_link,
remove_unique_withdraw_link,
)
from .models import WithdrawLink
@withdraw_ext.get(
"/api/v1/lnurl/{unique_hash}",
response_class=JSONResponse,
name="withdraw.api_lnurl_response",
)
async def api_lnurl_response(request: Request, unique_hash: str):
link = await get_withdraw_link_by_hash(unique_hash)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
)
if link.is_spent:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
)
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
# Check if url is .onion and change to http
if urlparse(url).netloc.endswith(".onion"):
# change url string scheme to http
url = url.replace("https://", "http://")
return {
"tag": "withdrawRequest",
"callback": url,
"k1": link.k1,
"minWithdrawable": link.min_withdrawable * 1000,
"maxWithdrawable": link.max_withdrawable * 1000,
"defaultDescription": link.title,
"webhook_url": link.webhook_url,
"webhook_headers": link.webhook_headers,
"webhook_body": link.webhook_body,
}
@withdraw_ext.get(
"/api/v1/lnurl/cb/{unique_hash}",
name="withdraw.api_lnurl_callback",
summary="lnurl withdraw callback",
description="""
This endpoints allows you to put unique_hash, k1
and a payment_request to get your payment_request paid.
""",
response_class=JSONResponse,
response_description="JSON with status",
responses={
200: {"description": "status: OK"},
400: {"description": "k1 is wrong or link open time or withdraw not working."},
404: {"description": "withdraw link not found."},
405: {"description": "withdraw link is spent."},
},
)
async def api_lnurl_callback(
unique_hash,
k1: str = Query(...),
pr: str = Query(...),
id_unique_hash=None,
):
link = await get_withdraw_link_by_hash(unique_hash)
now = int(datetime.now().timestamp())
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
)
if link.is_spent:
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="withdraw is spent."
)
if link.k1 != k1:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="k1 is wrong.")
if now < link.open_time:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"wait link open_time {link.open_time - now} seconds.",
)
if id_unique_hash:
if check_unique_link(link, id_unique_hash):
await remove_unique_withdraw_link(link, id_unique_hash)
else:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
)
try:
payment_hash = await pay_invoice(
wallet_id=link.wallet,
payment_request=pr,
max_sat=link.max_withdrawable,
extra={"tag": "withdraw"},
)
await increment_withdraw_link(link)
if link.webhook_url:
await dispatch_webhook(link, payment_hash, pr)
return {"status": "OK"}
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=f"withdraw not working. {str(e)}"
)
def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
return any(
unique_hash == shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
for x in link.usescsv.split(",")
)
async def dispatch_webhook(
link: WithdrawLink, payment_hash: str, payment_request: str
) -> None:
async with httpx.AsyncClient() as client:
try:
r: httpx.Response = await client.post(
link.webhook_url,
json={
"payment_hash": payment_hash,
"payment_request": payment_request,
"lnurlw": link.id,
"body": json.loads(link.webhook_body) if link.webhook_body else "",
},
headers=json.loads(link.webhook_headers)
if link.webhook_headers
else None,
timeout=40,
)
await update_payment_extra(
payment_hash=payment_hash,
extra={
"wh_success": r.is_success,
"wh_message": r.reason_phrase,
"wh_response": r.text,
},
outgoing=True,
)
except Exception as exc:
# webhook fails shouldn't cause the lnurlw to fail since invoice is already paid
logger.error(f"Caught exception when dispatching webhook url: {str(exc)}")
await update_payment_extra(
payment_hash=payment_hash,
extra={"wh_success": False, "wh_message": str(exc)},
outgoing=True,
)
# FOR LNURLs WHICH ARE UNIQUE
@withdraw_ext.get(
"/api/v1/lnurl/{unique_hash}/{id_unique_hash}",
response_class=JSONResponse,
name="withdraw.api_lnurl_multi_response",
)
async def api_lnurl_multi_response(request: Request, unique_hash: str, id_unique_hash: str):
link = await get_withdraw_link_by_hash(unique_hash)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
)
if link.is_spent:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
)
if not check_unique_link(link, id_unique_hash):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
)
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
# Check if url is .onion and change to http
if urlparse(url).netloc.endswith(".onion"):
# change url string scheme to http
url = url.replace("https://", "http://")
return {
"tag": "withdrawRequest",
"callback": f"{url}?id_unique_hash={id_unique_hash}",
"k1": link.k1,
"minWithdrawable": link.min_withdrawable * 1000,
"maxWithdrawable": link.max_withdrawable * 1000,
"defaultDescription": link.title,
}

View file

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

View file

@ -132,3 +132,16 @@ async def m006_webhook_headers_and_body(db):
"ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_headers TEXT;" "ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_headers TEXT;"
) )
await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_body TEXT;") await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_body TEXT;")
async def m007_add_created_at_timestamp(db):
await db.execute(
"ALTER TABLE withdraw.withdraw_link "
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
)
async def m008_add_enabled_column(db):
await db.execute(
"ALTER TABLE withdraw.withdraw_link ADD COLUMN enabled BOOLEAN DEFAULT true;"
)

44
migrations_fork.py Normal file
View file

@ -0,0 +1,44 @@
"""
Fork-specific database migrations for the aiolabs withdraw extension.
These migrations are tracked separately under `withdraw_fork` in the
`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`),
so they do not collide with upstream's `m{NNN}_*` numbering in
`migrations.py`. Keeping the upstream-tracked file untouched means
`git pull upstream` stays rebase-clean for schema changes.
Conventions:
- Sequential numbering starting from m001.
- Each migration is `async def m{NNN}_<description>(db)`.
- DDL must be idempotent: a fresh install runs every migration; an
install that already carries the column must not crash. Use
`_alter_add_column_safe` so re-runs are no-ops.
"""
async def _alter_add_column_safe(db, sql: str) -> None:
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors, so a
re-run on a DB that already has the column is a silent no-op."""
try:
await db.execute(sql)
except Exception as exc:
msg = str(exc).lower()
if "duplicate column" in msg or "already exists" in msg:
return
raise
async def m001_aio_withdraw_schema(db):
"""
Apply every aiolabs schema delta on top of upstream withdraw.
`withdraw_link.extra` arbitrary JSON merged into the payout payment's
`extra` when the link is claimed (see views_lnurl). Lets a caller tag the
resulting payment with settlement/attribution metadata an external listener
can key on e.g. bitSpire stamps {source, type, principal_sats, fee_sats,
...} so the spirekeeper cash-in settlement fires off an LNURL-withdraw
payout. Stored as TEXT; (de)serialized to a dict by the WithdrawLink model.
"""
await _alter_add_column_safe(
db, "ALTER TABLE withdraw.withdraw_link ADD COLUMN extra TEXT"
)

View file

@ -1,10 +1,7 @@
import shortuuid from datetime import datetime
from fastapi import Query from fastapi import Query
from lnurl import Lnurl, LnurlWithdrawResponse from pydantic import BaseModel, Field
from lnurl import encode as lnurl_encode
from lnurl.models import ClearnetUrl, MilliSatoshi
from pydantic import BaseModel
from starlette.requests import Request
class CreateWithdrawData(BaseModel): class CreateWithdrawData(BaseModel):
@ -18,6 +15,13 @@ class CreateWithdrawData(BaseModel):
webhook_headers: str = Query(None) webhook_headers: str = Query(None)
webhook_body: str = Query(None) webhook_body: str = Query(None)
custom_url: str = Query(None) custom_url: str = Query(None)
enabled: bool = Query(True)
# Arbitrary JSON merged into the payout payment's `extra` when this link is
# claimed (see views_lnurl). Lets a caller tag the resulting payment with
# settlement/attribution metadata an external listener can key on — e.g.
# bitSpire stamps {source, type, principal_sats, fee_sats, ...} so the
# spirekeeper cash-in settlement fires off an LNURL-withdraw payout.
extra: dict | None = None
class WithdrawLink(BaseModel): class WithdrawLink(BaseModel):
@ -34,46 +38,43 @@ class WithdrawLink(BaseModel):
open_time: int = Query(0) open_time: int = Query(0)
used: int = Query(0) used: int = Query(0)
usescsv: str = Query(None) usescsv: str = Query(None)
number: int = Query(0) number: int = Field(default=0, no_database=True)
webhook_url: str = Query(None) webhook_url: str = Query(None)
webhook_headers: str = Query(None) webhook_headers: str = Query(None)
webhook_body: str = Query(None) webhook_body: str = Query(None)
custom_url: str = Query(None) custom_url: str = Query(None)
# Persisted as TEXT (JSON); merged into the payout payment's `extra` on
# claim. LNbits' db layer (de)serializes dict-typed columns to/from JSON
# natively (same as Payment.extra) — no per-field validator needed.
extra: dict | None = None
created_at: datetime
enabled: bool = Query(True)
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: lnurlw://${window.location.hostname}/lnurlw/${id}"
),
)
lnurl_url: str | None = Field(
default=None,
no_database=True,
description="The raw LNURL callback URL (use for QR code generation)",
)
@property @property
def is_spent(self) -> bool: def is_spent(self) -> bool:
return self.used >= self.uses return self.used >= self.uses
def lnurl(self, req: Request) -> Lnurl:
if self.is_unique:
usescssv = self.usescsv.split(",")
tohash = self.id + self.unique_hash + usescssv[self.number]
multihash = shortuuid.uuid(name=tohash)
url = req.url_for(
"withdraw.api_lnurl_multi_response",
unique_hash=self.unique_hash,
id_unique_hash=multihash,
)
else:
url = req.url_for(
"withdraw.api_lnurl_response", unique_hash=self.unique_hash
)
return lnurl_encode(str(url))
def lnurl_response(self, req: Request) -> LnurlWithdrawResponse:
url = req.url_for(
name="withdraw.api_lnurl_callback", unique_hash=self.unique_hash
)
return LnurlWithdrawResponse(
callback=ClearnetUrl(str(url), scheme="https"),
k1=self.k1,
minWithdrawable=MilliSatoshi(self.min_withdrawable * 1000),
maxWithdrawable=MilliSatoshi(self.max_withdrawable * 1000),
defaultDescription=self.title,
)
class HashCheck(BaseModel): class HashCheck(BaseModel):
hash: bool hash: bool
lnurl: bool lnurl: bool
class PaginatedWithdraws(BaseModel):
data: list[WithdrawLink]
total: int

59
package-lock.json generated Normal file
View file

@ -0,0 +1,59 @@
{
"name": "withdraw",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "withdraw",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
},
"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,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prettier": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pyright": {
"version": "1.1.359",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.359.tgz",
"integrity": "sha512-rtdQDlVfZy10MUDuTlY75wKaQt4hbd/kSAKHIJqaStZs4UPQMVrhpZBEDf1NQGAiSGCuKQn0qVpNNuGUEicqlQ==",
"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": "withdraw",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
}

88
pyproject.toml Normal file
View file

@ -0,0 +1,88 @@
[project]
name = "lnbits-withdraw"
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/bitcoinswitch_extension" }
dependencies = [ "lnbits>1" ]
[tool.poetry]
package-mode = false
[tool.uv]
dev-dependencies = [
"black",
"pytest-asyncio",
"pytest",
"mypy",
"pre-commit",
"ruff",
"pytest-md",
]
[tool.mypy]
plugins = ["pydantic.mypy"]
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = 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
[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"]
# UP007: pyupgrade: use X | Y instead of Optional. (python3.10)
# C901 `api_link_create_or_update` is too complex (15 > 10)
ignore = ["UP007", "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",
]

BIN
static/image/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
static/image/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
static/image/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
static/image/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

BIN
static/image/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View file

@ -1,42 +1,33 @@
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */ const mapWithdrawLink = function (obj) {
Vue.component(VueQrcode.name, VueQrcode)
var locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
var mapWithdrawLink = function (obj) {
obj._data = _.clone(obj) obj._data = _.clone(obj)
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.min_fsat = new Intl.NumberFormat(LOCALE).format(obj.min_withdrawable)
obj.max_fsat = new Intl.NumberFormat(LOCALE).format(obj.max_withdrawable)
obj.uses_left = obj.uses - obj.used obj.uses_left = obj.uses - obj.used
obj.print_url = [locationPath, 'print/', obj.id].join('')
obj.withdraw_url = [locationPath, obj.id].join('')
obj._data.use_custom = Boolean(obj.custom_url) obj._data.use_custom = Boolean(obj.custom_url)
return obj return obj
} }
const CUSTOM_URL = '/static/images/default_voucher.png' const CUSTOM_URL = '/static/images/default_voucher.png'
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data() {
return { return {
checker: null, checker: null,
withdrawLinks: [], withdrawLinks: [],
lnurl: '',
withdrawLinksTable: { withdrawLinksTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'title', align: 'left', label: 'Title', field: 'title'}, {name: 'title', align: 'left', label: 'Title', field: 'title'},
{
name: 'created_at',
align: 'left',
label: 'Created At',
field: 'created_at',
sortable: true,
format: function (val) {
return new Date(val).toLocaleString()
}
},
{ {
name: 'wait_time', name: 'wait_time',
align: 'right', align: 'right',
@ -46,7 +37,7 @@ new Vue({
{ {
name: 'uses', name: 'uses',
align: 'right', align: 'right',
label: 'Created', label: 'Uses',
field: 'uses' field: 'uses'
}, },
{ {
@ -55,11 +46,18 @@ new Vue({
label: 'Uses left', label: 'Uses left',
field: 'uses_left' field: 'uses_left'
}, },
{name: 'min', align: 'right', label: 'Min (sat)', field: 'min_fsat'}, {
{name: 'max', align: 'right', label: 'Max (sat)', field: 'max_fsat'} name: 'max_withdrawable',
align: 'right',
label: 'Max (sat)',
field: 'max_withdrawable',
format: LNbits.utils.formatSat
}
], ],
pagination: { pagination: {
rowsPerPage: 10 page: 1,
rowsPerPage: 10,
rowsNumber: 0
} }
}, },
nfcTagWriting: false, nfcTagWriting: false,
@ -70,7 +68,8 @@ new Vue({
data: { data: {
is_unique: false, is_unique: false,
use_custom: false, use_custom: false,
has_webhook: false has_webhook: false,
enabled: true
} }
}, },
simpleformDialog: { simpleformDialog: {
@ -80,7 +79,8 @@ new Vue({
use_custom: false, use_custom: false,
title: 'Vouchers', title: 'Vouchers',
min_withdrawable: 0, min_withdrawable: 0,
wait_time: 1 wait_time: 1,
enabled: true
} }
}, },
qrCodeDialog: { qrCodeDialog: {
@ -90,64 +90,71 @@ new Vue({
} }
}, },
computed: { computed: {
sortedWithdrawLinks: function () { sortedWithdrawLinks() {
return this.withdrawLinks.sort(function (a, b) { return this.withdrawLinks.sort(function (a, b) {
return b.uses_left - a.uses_left return b.uses_left - a.uses_left
}) })
} }
}, },
methods: { methods: {
getWithdrawLinks: function () { getWithdrawLinks(props) {
var self = this if (props) {
this.withdrawLinksTable.pagination = props.pagination
}
let pagination = this.withdrawLinksTable.pagination
const query = {
limit: pagination.rowsPerPage,
offset: (pagination.page - 1) * pagination.rowsPerPage
}
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/withdraw/api/v1/links?all_wallets=true', `/withdraw/api/v1/links?all_wallets=true&limit=${query.limit}&offset=${query.offset}`,
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(function (response) { .then(response => {
self.withdrawLinks = response.data.map(function (obj) { this.withdrawLinks = response.data.data.map(mapWithdrawLink)
return mapWithdrawLink(obj) this.withdrawLinksTable.pagination.rowsNumber = response.data.total
})
}) })
.catch(function (error) { .catch(error => {
clearInterval(self.checker) clearInterval(this.checker)
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
closeFormDialog: function () { closeFormDialog() {
this.formDialog.data = { this.formDialog.data = {
is_unique: false, is_unique: false,
use_custom: false, use_custom: false,
has_webhook: false has_webhook: false,
enabled: true
} }
}, },
simplecloseFormDialog: function () { simplecloseFormDialog() {
this.simpleformDialog.data = { this.simpleformDialog.data = {
is_unique: false, is_unique: false,
use_custom: false use_custom: false,
enabled: true
} }
}, },
openQrCodeDialog: function (linkId) { openQrCodeDialog(linkId) {
var link = _.findWhere(this.withdrawLinks, {id: linkId}) const link = _.findWhere(this.withdrawLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link) this.qrCodeDialog.data = _.clone(link)
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true this.qrCodeDialog.show = true
this.activeUrl = link.lnurl_url
}, },
openUpdateDialog: function (linkId) { openUpdateDialog(linkId) {
var link = _.findWhere(this.withdrawLinks, {id: linkId}) let link = _.findWhere(this.withdrawLinks, {id: linkId})
link._data.has_webhook = link._data.webhook_url ? true : false link._data.has_webhook = link._data.webhook_url ? true : false
this.formDialog.data = _.clone(link._data) this.formDialog.data = _.clone(link._data)
this.formDialog.show = true this.formDialog.show = true
}, },
sendFormData: function () { sendFormData() {
var 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 = _.omit(this.formDialog.data, 'wallet')
if (!data.use_custom) { if (!data.use_custom) {
data.custom_url = null data.custom_url = null
@ -171,11 +178,11 @@ new Vue({
this.createWithdrawLink(wallet, data) this.createWithdrawLink(wallet, data)
} }
}, },
simplesendFormData: function () { simplesendFormData() {
var wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.simpleformDialog.data.wallet id: this.simpleformDialog.data.wallet
}) })
var data = _.omit(this.simpleformDialog.data, 'wallet') const data = _.omit(this.simpleformDialog.data, 'wallet')
data.wait_time = 1 data.wait_time = 1
data.min_withdrawable = data.max_withdrawable data.min_withdrawable = data.max_withdrawable
@ -196,9 +203,7 @@ new Vue({
this.createWithdrawLink(wallet, data) this.createWithdrawLink(wallet, data)
} }
}, },
updateWithdrawLink: function (wallet, data) { updateWithdrawLink(wallet, data) {
var self = this
// Remove webhook info if toggle is set to false // Remove webhook info if toggle is set to false
if (!data.has_webhook) { if (!data.has_webhook) {
data.webhook_url = null data.webhook_url = null
@ -213,48 +218,45 @@ new Vue({
wallet.adminkey, wallet.adminkey,
data data
) )
.then((response) => { .then(response => {
self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) {
return obj.id === data.id return obj.id === data.id
}) })
self.withdrawLinks.push(mapWithdrawLink(response.data)) this.withdrawLinks.push(mapWithdrawLink(response.data))
self.formDialog.show = false this.formDialog.show = false
this.closeFormDialog() this.closeFormDialog()
}) })
.catch(function (error) { .catch(error => {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
createWithdrawLink: function (wallet, data) { createWithdrawLink(wallet, data) {
var self = this
LNbits.api LNbits.api
.request('POST', '/withdraw/api/v1/links', wallet.adminkey, data) .request('POST', '/withdraw/api/v1/links', wallet.adminkey, data)
.then((response) => { .then(response => {
self.withdrawLinks.push(mapWithdrawLink(response.data)) this.withdrawLinks.push(mapWithdrawLink(response.data))
self.formDialog.show = false this.formDialog.show = false
self.simpleformDialog.show = false this.simpleformDialog.show = false
this.closeFormDialog() this.closeFormDialog()
}) })
.catch(function (error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
deleteWithdrawLink: function (linkId) { deleteWithdrawLink(linkId) {
var self = this const link = _.findWhere(this.withdrawLinks, {id: linkId})
var link = _.findWhere(this.withdrawLinks, {id: linkId})
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this withdraw link?') .confirmDialog('Are you sure you want to delete this withdraw link?')
.onOk(function () { .onOk(() => {
LNbits.api LNbits.api
.request( .request(
'DELETE', 'DELETE',
'/withdraw/api/v1/links/' + linkId, '/withdraw/api/v1/links/' + linkId,
_.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
) )
.then(function (response) { .then(() => {
self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) {
return obj.id === linkId return obj.id === linkId
}) })
}) })
@ -263,7 +265,7 @@ new Vue({
}) })
}) })
}, },
writeNfcTag: async function (lnurl) { async writeNfcTag(lnurl) {
try { try {
if (typeof NDEFReader == 'undefined') { if (typeof NDEFReader == 'undefined') {
throw { throw {
@ -305,15 +307,12 @@ new Vue({
this.withdrawLinks, this.withdrawLinks,
'withdraw-links' 'withdraw-links'
) )
}, }
}, },
created: function () { created() {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
var getWithdrawLinks = this.getWithdrawLinks this.getWithdrawLinks()
getWithdrawLinks() this.checker = setInterval(this.getWithdrawLinks, 300000)
this.checker = setInterval(function () {
getWithdrawLinks()
}, 300000)
} }
} }
}) })

View file

@ -24,7 +24,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X GET {{ request.base_url }}withdraw/api/v1/links -H >curl -X GET {{ request.base_url }}withdraw/api/v1/links -H
"X-Api-Key: {{ user.wallets[0].inkey }}" "X-Api-Key: <span v-text="g.user.wallets[0].inkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -51,8 +51,8 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X GET {{ request.base_url >curl -X GET {{ request.base_url
}}withdraw/api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key: {{ }}withdraw/api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key:
user.wallets[0].inkey }}" <span v-text="g.user.wallets[0].inkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -86,7 +86,7 @@
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;, "max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;, "wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;,
"webhook_url": &lt;string&gt;}' -H "Content-type: application/json" -H "webhook_url": &lt;string&gt;}' -H "Content-type: application/json" -H
"X-Api-Key: {{ user.wallets[0].adminkey }}" "X-Api-Key: <span v-text="g.user.wallets[0].adminkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -122,8 +122,8 @@
&lt;string&gt;, "min_withdrawable": &lt;integer&gt;, &lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;, "max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -H "wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{ "Content-type: application/json" -H "X-Api-Key:
user.wallets[0].adminkey }}" <span v-text="g.user.wallets[0].adminkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -147,8 +147,8 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X DELETE {{ request.base_url >curl -X DELETE {{ request.base_url
}}withdraw/api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key: {{ }}withdraw/api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key:
user.wallets[0].adminkey }}" <span v-text="g.user.wallets[0].adminkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -176,7 +176,7 @@
<code <code
>curl -X GET {{ request.base_url >curl -X GET {{ request.base_url
}}withdraw/api/v1/links/&lt;the_hash&gt;/&lt;lnurl_id&gt; -H }}withdraw/api/v1/links/&lt;the_hash&gt;/&lt;lnurl_id&gt; -H
"X-Api-Key: {{ user.wallets[0].inkey }}" "X-Api-Key: <span v-text="g.user.wallets[0].inkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>

View file

@ -1,12 +0,0 @@
{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes
in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor
%} {% endblock %} {% block scripts %}
<script>
new Vue({
el: '#vue',
data: function () {
return {}
}
})
</script>
{% endblock %}

View file

@ -4,29 +4,32 @@
<q-card class="q-pa-lg"> <q-card class="q-pa-lg">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<div class="text-center"> <div class="text-center">
{% if link.is_spent %} <q-badge v-if="spent" color="red" class="q-mb-md"
<q-badge color="red" class="q-mb-md">Withdraw is spent.</q-badge> >Withdraw is spent.</q-badge
{% endif %} >
<a class="text-secondary" href="lightning:{{ lnurl }}"> <q-badge v-if="spent" color="red" class="q-mb-md"
<q-responsive :ratio="1" class="q-mx-md"> >Withdraw is spent.</q-badge
<qrcode >
:value="this.here + '/?lightning={{lnurl }}'" <q-badge v-else-if="!enabled" color="grey" class="q-mb-md"
:options="{width: 800}" >Withdraw is disabled.</q-badge
class="rounded-borders" >
> <a v-else class="text-secondary" :href="link">
</qrcode> <lnbits-qrcode-lnurl
</q-responsive> prefix="lnurlw"
:url="url"
@update:lnurl="v => lnurl = v"
></lnbits-qrcode-lnurl>
</a> </a>
</div> </div>
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
<q-btn outline color="grey" @click="copyText('{{ lnurl }}')" <q-btn outline color="grey" @click="copyText(lnurl)"
>Copy LNURL</q-btn >Copy LNURL</q-btn
> >
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="nfc" icon="nfc"
@click="writeNfcTag(' {{ lnurl }} ')" @click="writeNfcTag(lnurl)"
:disable="nfcTagWriting" :disable="nfcTagWriting"
></q-btn> ></q-btn>
</div> </div>
@ -52,15 +55,16 @@
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) window.app = Vue.createApp({
new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data() {
return { return {
here: location.protocol + '//' + location.host, spent: {{ 'true' if spent else 'false' }},
nfcTagWriting: false url: '{{ lnurl_url }}',
lnurl: '',
nfcTagWriting: false,
enabled: {{ 'true' if enabled else 'false' }}
} }
} }
}) })

View file

@ -1,7 +1,5 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} %} {% block page %}
<script src="/withdraw/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md"> <div class="col-12 col-md-7 q-gutter-y-md">
<q-card> <q-card>
@ -9,7 +7,11 @@
<q-btn unelevated color="primary" @click="simpleformDialog.show = true" <q-btn unelevated color="primary" @click="simpleformDialog.show = true"
>Quick vouchers</q-btn >Quick vouchers</q-btn
> >
<q-btn unelevated color="primary" @click="formDialog.show = true" <q-btn
unelevated
color="primary"
@click="formDialog.show = true"
class="q-ml-md"
>Advanced withdraw link(s)</q-btn >Advanced withdraw link(s)</q-btn
> >
</q-card-section> </q-card-section>
@ -28,77 +30,72 @@
<q-table <q-table
dense dense
flat flat
:data="sortedWithdrawLinks" :rows="sortedWithdrawLinks"
row-key="id" row-key="id"
:columns="withdrawLinksTable.columns" :columns="withdrawLinksTable.columns"
:pagination.sync="withdrawLinksTable.pagination" v-model:pagination="withdrawLinksTable.pagination"
@request="getWithdrawLinks"
> >
{% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
<q-th auto-width></q-th>
</q-tr> </q-tr>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width>
<q-icon
name="power_settings_new"
:color="props.row.enabled ? 'green' : 'red'"
size="xs"
>
<q-tooltip>
<span
v-text="props.row.enabled ? 'Withdraw link is enabled' : 'Withdraw link is disabled'"
></span>
</q-tooltip>
</q-icon>
</q-td>
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
unelevated unelevated
dense dense
size="xs" size="xs"
icon="launch" icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a" type="a"
:href="props.row.withdraw_url" :href="'/withdraw/' + props.row.id"
target="_blank" target="_blank"
> >
<q-tooltip> shareable link </q-tooltip></q-btn <q-tooltip>Shareable link</q-tooltip></q-btn
>
<q-btn
unelevated
dense
size="xs"
icon="web_asset"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/withdraw/img/' + props.row.id"
target="_blank"
><q-tooltip> embeddable image </q-tooltip></q-btn
> >
<q-btn <q-btn
unelevated unelevated
dense dense
size="xs" size="xs"
icon="reorder" icon="reorder"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a" type="a"
:href="'/withdraw/csv/' + props.row.id" :href="'/withdraw/csv/' + props.row.id"
target="_blank" target="_blank"
><q-tooltip> csv list </q-tooltip></q-btn ><q-tooltip>CSV download</q-tooltip></q-btn
> >
<q-btn <q-btn
unelevated unelevated
dense dense
size="xs" size="xs"
icon="visibility" icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)" @click="openQrCodeDialog(props.row.id)"
><q-tooltip> view LNURL </q-tooltip></q-btn ><q-tooltip>view LNURL</q-tooltip></q-btn
> >
</q-td> </q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</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-td>
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
flat flat
@ -117,9 +114,22 @@
color="pink" color="pink"
></q-btn> ></q-btn>
</q-td> </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-td>
</q-tr> </q-tr>
</template> </template>
{% endraw %}
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -129,7 +139,7 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none"> <h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} LNURL-withdraw extension LNbits LNURL withdraw extension
</h6> </h6>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
@ -211,7 +221,7 @@
</div> </div>
<q-toggle <q-toggle
label="Webhook" label="Webhook"
color="secodary" color="secondary"
v-model="formDialog.data.has_webhook" v-model="formDialog.data.has_webhook"
></q-toggle> ></q-toggle>
<q-input <q-input
@ -242,6 +252,20 @@
hint="Custom data as JSON string, will get posted along with webhook 'body' field." hint="Custom data as JSON string, will get posted along with webhook 'body' field."
></q-input> ></q-input>
<q-list> <q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
<q-checkbox
v-model="formDialog.data.enabled"
color="primary"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label>Enable / Disable </q-item-label>
<q-item-label caption
>You can enable or disable these vouchers</q-item-label
>
</q-item-section>
</q-item>
<q-item tag="label" class="rounded-borders"> <q-item tag="label" class="rounded-borders">
<q-item-section avatar> <q-item-section avatar>
<q-checkbox <q-checkbox
@ -354,6 +378,20 @@
label="Number of vouchers" label="Number of vouchers"
></q-input> ></q-input>
<q-list> <q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
<q-checkbox
v-model="simpleformDialog.data.enabled"
color="primary"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label>Enable / Disable </q-item-label>
<q-item-label caption
>You can enable or disable these vouchers</q-item-label
>
</q-item-section>
</q-item>
<q-item tag="label" class="rounded-borders"> <q-item tag="label" class="rounded-borders">
<q-item-section avatar> <q-item-section avatar>
<q-checkbox <q-checkbox
@ -403,63 +441,61 @@
<q-dialog v-model="qrCodeDialog.show" position="top"> <q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card"> <q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md"> <lnbits-qrcode-lnurl
<qrcode :url="activeUrl"
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl" @update:lnurl="v => lnurl = v"
:options="{width: 800}" prefix="lnurlw"
class="rounded-borders" ></lnbits-qrcode-lnurl>
></qrcode>
{% raw %}
</q-responsive>
<p style="word-break: break-all"> <p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br /> <strong>ID:</strong> <span v-text="qrCodeDialog.data.id"></span><br />
<strong>Unique:</strong> {{ qrCodeDialog.data.is_unique }}<span <strong>Unique:</strong>
v-if="qrCodeDialog.data.is_unique" <span v-text="qrCodeDialog.data.is_unique"></span>
class="text-deep-purple" <span v-if="qrCodeDialog.data.is_unique" class="text-deep-purple">
>
(QR code will change after each withdrawal)</span (QR code will change after each withdrawal)</span
><br /> ><br />
<strong>Max. withdrawable:</strong> {{ <strong>Max. withdrawable:</strong>
qrCodeDialog.data.max_withdrawable }} sat<br /> <span v-text="qrCodeDialog.data.max_withdrawable"></span> sat<br />
<strong>Wait time:</strong> {{ qrCodeDialog.data.wait_time }} seconds<br /> <strong>Wait time:</strong>
<strong>Withdraws:</strong> {{ qrCodeDialog.data.used }} / {{ <span v-text="qrCodeDialog.data.wait_time"></span> seconds<br />
qrCodeDialog.data.uses }} <strong>Withdraws:</strong>
<span v-text="qrCodeDialog.data.used"></span>/
<span v-text="qrCodeDialog.data.uses"></span><br />
<q-linear-progress <q-linear-progress
:value="qrCodeDialog.data.used / qrCodeDialog.data.uses" :value="qrCodeDialog.data.used / qrCodeDialog.data.uses"
color="primary" color="primary"
class="q-mt-sm" class="q-mt-sm"
></q-linear-progress> ></q-linear-progress>
</p> </p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
<q-btn <q-btn
outline outline
color="grey" color="grey"
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')" @click="copyText(lnurl, 'LNURL copied to clipboard!')"
class="q-ml-sm" class="q-ml-sm"
>Copy LNURL</q-btn >Copy LNURL</q-btn
> >
<q-btn
outline
color="grey"
icon="link"
@click="copyText(qrCodeDialog.data.withdraw_url, 'Link copied to clipboard!')"
><q-tooltip>Copy sharable link</q-tooltip>
</q-btn>
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="nfc" icon="nfc"
@click="writeNfcTag(qrCodeDialog.data.lnurl)" @click="writeNfcTag(lnurl)"
:disable="nfcTagWriting" :disable="nfcTagWriting"
><q-tooltip>Write to NFC</q-tooltip></q-btn ><q-tooltip>Write to NFC</q-tooltip></q-btn
> >
<q-btn
outline
color="grey"
icon="link"
:href="'/withdraw/' + qrCodeDialog.data.id"
target="_blank"
><q-tooltip>Open sharable link</q-tooltip>
</q-btn>
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="print" icon="print"
type="a" type="a"
:href="qrCodeDialog.data.print_url" :href="'/withdraw/print/' + qrCodeDialog.data.id"
target="_blank" target="_blank"
><q-tooltip>Print</q-tooltip></q-btn ><q-tooltip>Print</q-tooltip></q-btn
> >
@ -468,4 +504,6 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>
{% endblock %}{% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('withdraw/static', path='js/index.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -4,22 +4,21 @@
<div class="" id="vue"> <div class="" id="vue">
{% for page in link %} {% for page in link %}
<page size="A4" id="pdfprint"> <page size="A4" id="pdfprint">
<table style="width: 100%"> <div class="full-height content-center">
{% for threes in page %} {% for row in page %}
<tr style="height: 59.4mm"> <div class="row" style="max-height: 54mm">
{% for one in threes %} {% for one in row %}
<td style="width: 105mm"> <div class="col-6">
<center> <lnbits-qrcode
<qrcode style="width: 50mm"
:value="theurl + '/?lightning={{one}}'" :value="theurl + '/?lightning={{one}}'"
:options="{width: 150}" :show-buttons="false"
></qrcode> ></lnbits-qrcode>
</center> </div>
</td>
{% endfor %} {% endfor %}
</tr> </div>
{% endfor %} {% endfor %}
</table> </div>
</page> </page>
{% endfor %} {% endfor %}
</div> </div>
@ -53,11 +52,9 @@
</style> </style>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) window.app = Vue.createApp({
new Vue({
el: '#vue', el: '#vue',
data: function () { data() {
return { return {
theurl: location.protocol + '//' + location.host, theurl: location.protocol + '//' + location.host,
printDialog: { printDialog: {

View file

@ -6,13 +6,14 @@
<page size="A4" id="pdfprint"> <page size="A4" id="pdfprint">
{% for one in page %} {% for one in page %}
<div class="wrapper"> <div class="wrapper">
<img src="{{custom_url}}" alt="..." /> <img class="lnurlw_design" src="{{custom_url}}" alt="..." />
<span>{{ amt }} sats</span> <span>{{ amt }} sats</span>
<div class="lnurlw"> <div class="lnurlw">
<qrcode <lnbits-qrcode
:value="theurl + '/?lightning={{one}}'" :value="theurl + '/?lightning={{one}}'"
:options="{width: 95, margin: 1}" :show-buttons="false"
></qrcode> :options="{width: 150}"
></lnbits-qrcode>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@ -52,7 +53,7 @@
top: calc(3.2mm + 1rem); top: calc(3.2mm + 1rem);
right: calc(4mm + 1rem); right: calc(4mm + 1rem);
} }
.wrapper img { .wrapper img.lnurlw_design {
display: block; display: block;
width: 187mm; width: 187mm;
height: auto; height: auto;
@ -61,9 +62,10 @@
.wrapper .lnurlw { .wrapper .lnurlw {
display: block; display: block;
position: absolute; position: absolute;
top: calc(7.3mm + 1rem); top: calc(3mm + 1rem);
left: calc(7.5mm + 1rem); left: calc(6mm + 1rem);
transform: rotate(45deg); transform: rotate(45deg);
width: 27mm;
} }
@media print { @media print {
@ -83,17 +85,15 @@
.wrapper .lnurlw { .wrapper .lnurlw {
display: block; display: block;
position: absolute; position: absolute;
top: 7.3mm; top: 3mm;
left: 7.5mm; left: 6mm;
transform: rotate(45deg); transform: rotate(45deg);
} }
} }
</style> </style>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) window.app = Vue.createApp({
new Vue({
el: '#vue', el: '#vue',
data: function () { data: function () {
return { return {

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 withdraw_ext
# just import router and add it to a test router
@pytest.mark.asyncio
async def test_router():
router = APIRouter()
router.include_router(withdraw_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].

225
transport_rpcs.py Normal file
View file

@ -0,0 +1,225 @@
"""
Nostr-transport RPC handlers for the withdraw (LNURL-withdraw) extension.
Names mirror the Lightning.Pub `withdraw.*` contract that the lamassu-next
ATM consumes (see ~/dev/shocknet/lamassu-next/packages/lightning/src/client.ts
lines ~301351). That keeps the lamassu-next-side adapter a pure name
translation no semantic reshaping.
Auth model (set in `__init__.py:withdraw_start`):
- create / get / update / delete AUTH_WALLET; the calling pubkey must
own the wallet the link is scoped to. *_get / *_update / *_delete also
verify the link's stored `wallet` matches the caller's wallet id.
`resolve_withdraw_owner` is registered with the core subscription module
under tag `"withdraw"` and extras-key `"withdrawal_link_id"` (matching
where the extension stamps the link id on settlement see
`views_lnurl.py:144`). That lets `subscribe_payments({tag:"withdraw",
link_id:...})` enforce ownership without core importing this module.
"""
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 shortuuid import uuid
from .crud import (
create_withdraw_link,
delete_withdraw_link,
get_withdraw_link,
get_withdraw_links,
update_withdraw_link,
)
from .helpers import create_lnurl_from_baseurl
from .models import CreateWithdrawData, WithdrawLink
async def handle_lnurlw_create_link(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
body = request.body or {}
data = CreateWithdrawData(**body)
link = await create_withdraw_link(data, auth.wallet.id)
return _to_dict(_populate_lnurl(link))
async def handle_lnurlw_get_link(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
link_id = _require_id(request)
link = await _require_owned_link(link_id, auth.wallet.id)
return _to_dict(_populate_lnurl(link))
async def handle_lnurlw_update_link(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
link_id = _require_id(request)
link = await _require_owned_link(link_id, auth.wallet.id)
body = request.body or {}
_MUTABLE = {
"title",
"min_withdrawable",
"max_withdrawable",
"uses",
"wait_time",
"is_unique",
"webhook_url",
"webhook_headers",
"webhook_body",
"custom_url",
"enabled",
}
for k, v in body.items():
if k in _MUTABLE:
setattr(link, k, v)
updated = await update_withdraw_link(link)
return _to_dict(_populate_lnurl(updated))
async def handle_lnurlw_delete_link(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
link_id = _require_id(request)
await _require_owned_link(link_id, auth.wallet.id)
await delete_withdraw_link(link_id)
return {"ok": True}
async def handle_lnurlw_list_links(auth: Account, request: NostrRpcRequest) -> dict:
"""List withdraw links across all wallets owned by the calling account.
Useful for ATMs to re-discover their links after a reconnect.
Body fields:
- limit: int (0 means no limit; default 0)
- offset: int (default 0)
If `request.wallet_id` is set and is one of the caller's wallets,
narrow to just that wallet.
"""
body = request.body or {}
limit = int(body.get("limit") or 0)
offset = int(body.get("offset") or 0)
wallets = await get_wallets(auth.id)
wallet_ids = [w.id for w in wallets]
if not wallet_ids:
return {"data": [], "total": 0}
if request.wallet_id and request.wallet_id in wallet_ids:
wallet_ids = [request.wallet_id]
page = await get_withdraw_links(wallet_ids, limit, offset)
return {
"data": [_to_dict(_populate_lnurl(link)) for link in page.data],
"total": page.total,
}
async def handle_lnurlw_unique_hashes(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
"""
For a `is_unique=True` link, return the per-use `id_unique_hash`
values that the ATM uses to generate distinct QR codes one per
unredeemed slot. Mirrors the formula in `helpers.py:create_lnurl`
exactly so an ATM never has to re-implement the derivation:
id_unique_hash = shortuuid.uuid(name=link.id + link.unique_hash + index)
`link.usescsv` is the canonical list of *unredeemed* slot indexes;
after a customer claims a slot it gets removed there (see
`crud.remove_unique_withdraw_link`). The hashes returned here are
therefore exactly the ones still claimable.
Response:
{
"link_id": str,
"unique_hash": str, # base hash
"is_unique": bool,
"unredeemed_hashes": [ # one entry per remaining slot
{"index": str, "id_unique_hash": str}, ...
]
}
For `is_unique=False` links the list is empty and `unique_hash`
alone identifies the callback path
(`/withdraw/api/v1/lnurl/<unique_hash>`). For `is_unique=True`
each callback path is
`/withdraw/api/v1/lnurl/<unique_hash>/<id_unique_hash>`.
"""
link_id = _require_id(request)
link = await _require_owned_link(link_id, auth.wallet.id)
unredeemed = []
if link.is_unique:
# usescsv is comma-separated; split and skip empties (after the
# last slot is consumed it becomes the empty string).
for index_str in [s for s in link.usescsv.split(",") if s.strip()]:
tohash = link.id + link.unique_hash + index_str
unredeemed.append(
{
"index": index_str.strip(),
"id_unique_hash": uuid(name=tohash),
}
)
return {
"link_id": link.id,
"unique_hash": link.unique_hash,
"is_unique": link.is_unique,
"unredeemed_hashes": unredeemed,
}
async def resolve_withdraw_owner(link_id: str) -> str | None:
"""For the core subscription module: link_id -> wallet_id (or None)."""
link = await get_withdraw_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("withdraw: body.id is required")
return str(link_id)
async def _require_owned_link(link_id: str, wallet_id: str):
link = await get_withdraw_link(link_id)
if link is None:
raise ValueError(f"withdraw: link not found: {link_id}")
if link.wallet != wallet_id:
raise PermissionError("withdraw: link does not belong to caller's wallet")
return link
def _populate_lnurl(link: WithdrawLink) -> WithdrawLink:
"""
Compose `lnurl` / `lnurl_url` from `settings.lnbits_baseurl` so
nostr-transport responses match the HTTP `views_api` shape, where
these fields are populated from `request.url_for(...)`. Without
this, consumers (ATMs, etc.) would have to re-derive the callback
URL themselves from a separately-provisioned LNbits HTTPS URL
duplicating state LNbits already knows. See aiolabs/withdraw#1.
"""
try:
encoded = create_lnurl_from_baseurl(link)
link.lnurl = str(encoded.bech32)
link.lnurl_url = str(encoded.url)
except ValueError:
pass
return link
def _to_dict(link) -> dict:
import json
return json.loads(link.json())

2267
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

123
views.py
View file

@ -1,28 +1,30 @@
import io
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO
import pyqrcode
from fastapi import Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse, StreamingResponse
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from . import withdraw_ext, withdraw_renderer
from .crud import chunks, get_withdraw_link from .crud import chunks, get_withdraw_link
from .helpers import create_lnurl
templates = Jinja2Templates(directory="templates") withdraw_ext_generic = APIRouter()
@withdraw_ext.get("/", response_class=HTMLResponse) def withdraw_renderer():
return template_renderer(["withdraw/templates"])
@withdraw_ext_generic.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(request: Request, user: User = Depends(check_user_exists)):
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/index.html", {"request": request, "user": user.dict()} "withdraw/index.html", {"request": request, "user": user.json()}
) )
@withdraw_ext.get("/{link_id}", response_class=HTMLResponse) @withdraw_ext_generic.get("/{link_id}", response_class=HTMLResponse)
async def display(request: Request, link_id): async def display(request: Request, link_id):
link = await get_withdraw_link(link_id, 0) link = await get_withdraw_link(link_id, 0)
@ -30,69 +32,56 @@ async def display(request: Request, link_id):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
) )
try:
lnurl = create_lnurl(link, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/display.html", "withdraw/display.html",
{ {
"request": request, "request": request,
"link": link.dict(), "spent": link.is_spent,
"lnurl": link.lnurl(req=request), "lnurl_url": str(lnurl.url),
"unique": True, "enabled": link.enabled,
}, },
) )
@withdraw_ext.get("/img/{link_id}", response_class=StreamingResponse) @withdraw_ext_generic.get("/print/{link_id}", response_class=HTMLResponse)
async def img(request: Request, link_id):
link = await get_withdraw_link(link_id, 0)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
)
qr = pyqrcode.create(link.lnurl(request))
stream = BytesIO()
qr.svg(stream, scale=3)
stream.seek(0)
async def _generator(stream: BytesIO):
yield stream.getvalue()
return StreamingResponse(
_generator(stream),
headers={
"Content-Type": "image/svg+xml",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
},
)
@withdraw_ext.get("/print/{link_id}", response_class=HTMLResponse)
async def print_qr(request: Request, link_id): async def print_qr(request: Request, link_id):
link = await get_withdraw_link(link_id) link = await get_withdraw_link(link_id)
if not link: if not link:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
) )
# response.status_code = HTTPStatus.NOT_FOUND
# return "Withdraw link does not exist."
if link.uses == 0: if link.uses == 0:
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/print_qr.html", "withdraw/print_qr.html",
{"request": request, "link": link.dict(), "unique": False}, {"request": request, "link": link.json(), "unique": False},
) )
links = [] links = []
count = 0 count = 0
for x in link.usescsv.split(","): for _ in link.usescsv.split(","):
linkk = await get_withdraw_link(link_id, count) linkk = await get_withdraw_link(link_id, count)
if not linkk: if not linkk:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
) )
links.append(str(linkk.lnurl(request))) try:
lnurl = create_lnurl(linkk, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
links.append(str(lnurl.bech32))
count = count + 1 count = count + 1
page_link = list(chunks(links, 2)) page_link = list(chunks(links, 2))
linked = list(chunks(page_link, 5)) linked = list(chunks(page_link, 5))
@ -114,36 +103,44 @@ async def print_qr(request: Request, link_id):
) )
@withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse) @withdraw_ext_generic.get("/csv/{link_id}", response_class=HTMLResponse)
async def csv(request: Request, link_id): async def csv(request: Request, link_id):
link = await get_withdraw_link(link_id) link = await get_withdraw_link(link_id)
if not link: if not link:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
) )
# response.status_code = HTTPStatus.NOT_FOUND
# return "Withdraw link does not exist."
if link.uses == 0: if link.uses == 0:
raise HTTPException(
return withdraw_renderer().TemplateResponse( status_code=HTTPStatus.BAD_REQUEST, detail="Withdraw is spent."
"withdraw/csv.html",
{"request": request, "link": link.dict(), "unique": False},
) )
links = []
count = 0
for x in link.usescsv.split(","): buffer = io.StringIO()
count = 0
for _ in link.usescsv.split(","):
linkk = await get_withdraw_link(link_id, count) linkk = await get_withdraw_link(link_id, count)
if not linkk: if not linkk:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
) )
links.append(str(linkk.lnurl(request))) try:
count = count + 1 lnurl = create_lnurl(linkk, request)
page_link = list(chunks(links, 2)) except ValueError as exc:
linked = list(chunks(page_link, 5)) raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
buffer.write(f"{lnurl.bech32!s}\n")
count += 1
return withdraw_renderer().TemplateResponse( # Move buffer cursor to the beginning
"withdraw/csv.html", {"request": request, "link": linked, "unique": True} buffer.seek(0)
return StreamingResponse(
buffer,
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=withdraw-links-{link_id}.csv"
},
) )

View file

@ -1,14 +1,11 @@
from http import HTTPStatus
from typing import Optional
import json import json
from http import HTTPStatus
from fastapi import Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key from lnbits.core.models import SimpleStatus, WalletTypeInfo
from lnbits.decorators import require_admin_key, require_invoice_key
from . import withdraw_ext
from .crud import ( from .crud import (
create_withdraw_link, create_withdraw_link,
delete_withdraw_link, delete_withdraw_link,
@ -17,38 +14,48 @@ from .crud import (
get_withdraw_links, get_withdraw_links,
update_withdraw_link, update_withdraw_link,
) )
from .models import CreateWithdrawData from .helpers import create_lnurl
from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
withdraw_ext_api = APIRouter(prefix="/api/v1")
@withdraw_ext.get("/api/v1/links", status_code=HTTPStatus.OK) @withdraw_ext_api.get("/links", status_code=HTTPStatus.OK)
async def api_links( async def api_links(
req: Request, request: Request,
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
): offset: int = Query(0),
wallet_ids = [wallet.wallet.id] limit: int = Query(0),
) -> PaginatedWithdraws:
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_withdraw_links(wallet_ids, limit, offset)
return [
{**link.dict(), **{"lnurl": link.lnurl(req)}}
for link in await get_withdraw_links(wallet_ids)
]
except LnurlInvalidUrl: for linkk in links.data:
raise HTTPException( try:
status_code=HTTPStatus.UPGRADE_REQUIRED, lnurl = create_lnurl(linkk, request)
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.", except ValueError as exc:
) raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
linkk.lnurl = str(lnurl.bech32)
linkk.lnurl_url = str(lnurl.url)
return links
@withdraw_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @withdraw_ext_api.get("/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_retrieve( async def api_link_retrieve(
link_id: str, request: Request, wallet: WalletTypeInfo = Depends(get_key_type) request: Request,
): link_id: str,
key_info: WalletTypeInfo = Depends(require_invoice_key),
) -> WithdrawLink:
link = await get_withdraw_link(link_id, 0) link = await get_withdraw_link(link_id, 0)
if not link: if not link:
@ -56,21 +63,31 @@ async def api_link_retrieve(
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
if link.wallet != wallet.wallet.id: if link.wallet != key_info.wallet.id:
raise HTTPException( raise HTTPException(
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
) )
return {**link.dict(), **{"lnurl": link.lnurl(request)}}
try:
lnurl = create_lnurl(link, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
link.lnurl = str(lnurl.bech32)
link.lnurl_url = str(lnurl.url)
return link
@withdraw_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) @withdraw_ext_api.post("/links", status_code=HTTPStatus.CREATED)
@withdraw_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @withdraw_ext_api.put("/links/{link_id}")
async def api_link_create_or_update( async def api_link_create_or_update(
req: Request, request: Request,
data: CreateWithdrawData, data: CreateWithdrawData,
link_id: Optional[str] = None, link_id: str | None = None,
wallet: WalletTypeInfo = Depends(require_admin_key), key_info: WalletTypeInfo = Depends(require_admin_key),
): ) -> WithdrawLink:
if data.uses > 250: if data.uses > 250:
raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST) raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST)
@ -88,20 +105,20 @@ async def api_link_create_or_update(
if data.webhook_body: if data.webhook_body:
try: try:
json.loads(data.webhook_body) json.loads(data.webhook_body)
except: except Exception as exc:
raise HTTPException( raise HTTPException(
detail="`webhook_body` can not parse JSON.", detail="`webhook_body` can not parse JSON.",
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
) ) from exc
if data.webhook_headers: if data.webhook_headers:
try: try:
json.loads(data.webhook_headers) json.loads(data.webhook_headers)
except: except Exception as exc:
raise HTTPException( raise HTTPException(
detail="`webhook_headers` can not parse JSON.", detail="`webhook_headers` can not parse JSON.",
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
) ) from exc
if link_id: if link_id:
link = await get_withdraw_link(link_id, 0) link = await get_withdraw_link(link_id, 0)
@ -109,39 +126,57 @@ async def api_link_create_or_update(
raise HTTPException( raise HTTPException(
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
if link.wallet != wallet.wallet.id: if link.wallet != key_info.wallet.id:
raise HTTPException( raise HTTPException(
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
) )
data_dict = data.dict()
if link.uses > data.uses: if link.uses > data.uses:
if data.uses - link.used <= 0: if data.uses - link.used <= 0:
raise HTTPException( raise HTTPException(
detail="Cannot reduce uses below current used.", status_code=HTTPStatus.BAD_REQUEST detail="Cannot reduce uses below current used.",
status_code=HTTPStatus.BAD_REQUEST,
) )
numbers = link.usescsv.split(",") numbers = link.usescsv.split(",")
usescsv = ",".join(numbers[:data.uses - link.used]) link.usescsv = ",".join(numbers[: data.uses - link.used])
data_dict["usescsv"] = usescsv
if link.uses < data.uses: if link.uses < data.uses:
numbers = link.usescsv.split(",") numbers = link.usescsv.split(",")
current_number = int(numbers[-1]) if numbers[-1] == "":
current_number = int(link.uses)
numbers[-1] = str(link.uses)
else:
current_number = int(numbers[-1])
while len(numbers) < (data.uses - link.used): while len(numbers) < (data.uses - link.used):
current_number += 1 current_number += 1
numbers.append(str(current_number)) numbers.append(str(current_number))
usescsv = ",".join(numbers) link.usescsv = ",".join(numbers)
data_dict["usescsv"] = usescsv
for k, v in data.dict().items():
link = await update_withdraw_link(link_id, **data_dict) if v is not None:
setattr(link, k, v)
link = await update_withdraw_link(link)
else: else:
link = await create_withdraw_link(wallet_id=wallet.wallet.id, data=data) link = await create_withdraw_link(wallet_id=key_info.wallet.id, data=data)
assert link try:
return {**link.dict(), **{"lnurl": link.lnurl(req)}} lnurl = create_lnurl(link, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
link.lnurl = str(lnurl.bech32)
link.lnurl_url = str(lnurl.url)
return link
@withdraw_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @withdraw_ext_api.delete("/links/{link_id}")
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)): async def api_link_delete(
link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> SimpleStatus:
link = await get_withdraw_link(link_id) link = await get_withdraw_link(link_id)
if not link: if not link:
@ -149,20 +184,20 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
if link.wallet != wallet.wallet.id: if link.wallet != key_info.wallet.id:
raise HTTPException( raise HTTPException(
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
) )
await delete_withdraw_link(link_id) await delete_withdraw_link(link_id)
return {"success": True} return SimpleStatus(success=True, message="Withdraw link deleted.")
@withdraw_ext.get( @withdraw_ext_api.get(
"/api/v1/links/{the_hash}/{lnurl_id}", "/links/{the_hash}/{lnurl_id}",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
dependencies=[Depends(get_key_type)], dependencies=[Depends(require_invoice_key)],
) )
async def api_hash_retrieve(the_hash, lnurl_id): async def api_hash_retrieve(the_hash, lnurl_id) -> HashCheck:
hashCheck = await get_hash_check(the_hash, lnurl_id) hash_check = await get_hash_check(the_hash, lnurl_id)
return hashCheck return hash_check

240
views_lnurl.py Normal file
View file

@ -0,0 +1,240 @@
import json
from datetime import datetime
import httpx
import shortuuid
from bolt11 import decode as decode_bolt11
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from lnbits.core.crud import update_payment
from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice
from lnurl import (
CallbackUrl,
LnurlErrorResponse,
LnurlSuccessResponse,
LnurlWithdrawResponse,
MilliSatoshi,
)
from loguru import logger
from pydantic import parse_obj_as
from .crud import (
create_hash_check,
delete_hash_check,
get_withdraw_link_by_hash,
increment_withdraw_link,
remove_unique_withdraw_link,
)
from .models import WithdrawLink
withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
@withdraw_ext_lnurl.get(
"/{unique_hash}",
response_class=JSONResponse,
name="withdraw.api_lnurl_response",
)
async def api_lnurl_response(
request: Request, unique_hash: str
) -> LnurlWithdrawResponse | LnurlErrorResponse:
link = await get_withdraw_link_by_hash(unique_hash)
if not link:
return LnurlErrorResponse(reason="Withdraw link does not exist.")
if not link.enabled:
return LnurlErrorResponse(reason="Withdraw link is disabled.")
if link.is_spent:
return LnurlErrorResponse(reason="Withdraw is spent.")
if link.is_unique:
return LnurlErrorResponse(reason="This link requires an id_unique_hash.")
url = str(
request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
)
callback_url = parse_obj_as(CallbackUrl, url)
return LnurlWithdrawResponse(
callback=callback_url,
k1=link.k1,
minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000),
maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000),
defaultDescription=link.title,
)
@withdraw_ext_lnurl.get(
"/cb/{unique_hash}",
name="withdraw.api_lnurl_callback",
summary="lnurl withdraw callback",
description="""
This endpoints allows you to put unique_hash, k1
and a payment_request to get your payment_request paid.
""",
response_class=JSONResponse,
response_description="JSON with status",
responses={
200: {"description": "status: OK"},
400: {"description": "k1 is wrong or link open time or withdraw not working."},
404: {"description": "withdraw link not found."},
405: {"description": "withdraw link is spent."},
},
)
async def api_lnurl_callback(
unique_hash: str,
k1: str,
pr: str,
id_unique_hash: str | None = None,
) -> LnurlErrorResponse | LnurlSuccessResponse:
link = await get_withdraw_link_by_hash(unique_hash)
if not link:
return LnurlErrorResponse(reason="withdraw link not found.")
if not link.enabled:
return LnurlErrorResponse(reason="Withdraw link is disabled.")
bolt11 = decode_bolt11(pr)
if not bolt11.amount_msat:
return LnurlErrorResponse(reason="0 amount invoices are not supported.")
if (
link.min_withdrawable * 1000 > bolt11.amount_msat
or bolt11.amount_msat > link.max_withdrawable * 1000
):
return LnurlErrorResponse(reason="Amount not within limits.")
if link.is_spent:
return LnurlErrorResponse(reason="withdraw is spent.")
if link.k1 != k1:
return LnurlErrorResponse(reason="k1 is wrong.")
now = int(datetime.now().timestamp())
if now < link.open_time + link.wait_time:
return LnurlErrorResponse(
reason=f"Wait {link.open_time + link.wait_time - now} seconds."
)
if not id_unique_hash and link.is_unique:
return LnurlErrorResponse(reason="id_unique_hash is required for this link.")
if id_unique_hash:
if check_unique_link(link, id_unique_hash):
await remove_unique_withdraw_link(link, id_unique_hash)
else:
return LnurlErrorResponse(reason="id_unique_hash not found.")
# Create a record with the id_unique_hash or unique_hash, if it already exists,
# raise an exception thus preventing the same LNURL from being processed twice.
try:
await create_hash_check(id_unique_hash or unique_hash, k1)
except Exception:
return LnurlErrorResponse(reason="LNURL already being processed.")
try:
payment = await pay_invoice(
wallet_id=link.wallet,
payment_request=pr,
max_sat=link.max_withdrawable,
# Merge the link's caller-supplied `extra` onto the payout so an
# external listener can key on it (e.g. bitSpire cash-in
# settlements via spirekeeper). The withdraw extension's own
# `tag`/`withdrawal_link_id` are written last so a caller cannot
# clobber them.
extra={
**(link.extra or {}),
"tag": "withdraw",
"withdrawal_link_id": link.id,
},
)
await increment_withdraw_link(link)
# If the payment succeeds, delete the record with the unique_hash.
# TODO: we delete this now: "If it has unique_hash, do not delete to prevent
# the same LNURL from being processed twice."
await delete_hash_check(id_unique_hash or unique_hash)
if link.webhook_url:
await dispatch_webhook(link, payment, pr)
return LnurlSuccessResponse()
except Exception as exc:
# If payment fails, delete the hash stored so another attempt can be made.
await delete_hash_check(id_unique_hash or unique_hash)
return LnurlErrorResponse(reason=f"withdraw not working. {exc!s}")
def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
return any(
unique_hash == shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
for x in link.usescsv.split(",")
)
async def dispatch_webhook(
link: WithdrawLink, payment: Payment, payment_request: str
) -> None:
async with httpx.AsyncClient() as client:
try:
r: httpx.Response = await client.post(
link.webhook_url,
json={
"payment_hash": payment.payment_hash,
"payment_request": payment_request,
"lnurlw": link.id,
"body": json.loads(link.webhook_body) if link.webhook_body else "",
},
headers=(
json.loads(link.webhook_headers) if link.webhook_headers else None
),
timeout=40,
)
payment.extra["wh_success"] = r.is_success
payment.extra["wh_message"] = r.reason_phrase
payment.extra["wh_response"] = r.text
await update_payment(payment)
except Exception as exc:
# webhook fails shouldn't cause the lnurlw to fail
# since invoice is already paid
logger.error(f"Caught exception when dispatching webhook url: {exc!s}")
payment.extra["wh_success"] = False
payment.extra["wh_message"] = str(exc)
await update_payment(payment)
# FOR LNURLs WHICH ARE UNIQUE
@withdraw_ext_lnurl.get(
"/{unique_hash}/{id_unique_hash}",
response_class=JSONResponse,
name="withdraw.api_lnurl_multi_response",
)
async def api_lnurl_multi_response(
request: Request, unique_hash: str, id_unique_hash: str
) -> LnurlWithdrawResponse | LnurlErrorResponse:
link = await get_withdraw_link_by_hash(unique_hash)
if not link:
return LnurlErrorResponse(reason="Withdraw link does not exist.")
if not link.enabled:
return LnurlErrorResponse(reason="Withdraw link is disabled.")
if link.is_spent:
return LnurlErrorResponse(reason="Withdraw is spent.")
if not check_unique_link(link, id_unique_hash):
return LnurlErrorResponse(reason="id_unique_hash not found for this link.")
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
callback_url = parse_obj_as(CallbackUrl, f"{url!s}?id_unique_hash={id_unique_hash}")
return LnurlWithdrawResponse(
callback=callback_url,
k1=link.k1,
minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000),
maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000),
defaultDescription=link.title,
)