Compare commits

..
Author SHA1 Message Date
Christian RotzollandGitHub f877dde2b0 fix #125 qrcode printing (#126)
Authored-by: rootzoll <rotzoll@MacBook2020.fritz.box>
2020-11-19 22:18:22 -03:00
fiatjaf 594e28eab7 formatting. 2020-11-18 22:26:30 -03:00
fiatjaf a67b93108b log errors on faulty lnpay webhooks. 2020-11-12 15:33:02 -03:00
fiatjaf a198842f50 wallet name on <title> 2020-11-12 15:32:41 -03:00
fiatjafandGitHub 22b31a91c7 Merge pull request #122 from lnbits/lnurl-auth 2020-11-12 10:02:55 -03:00
fiatjaf 54881d777e lnurl-auth: show wallet name on modal. 2020-11-11 22:43:01 -03:00
fiatjaf b7e337b0ee lnurl-auth: show the correct wallet linking_key on modal. 2020-11-11 22:42:40 -03:00
fiatjaf b794f8302d lnurl-auth: hashing_key -> linking_key. 2020-11-11 22:37:55 -03:00
fiatjaf 805000cd06 fix ecdsa signing because these libraries are too naïve. 2020-11-11 02:41:41 -03:00
fiatjaf bb94dc6526 fix perform_lnurl error handling. 2020-11-11 02:40:41 -03:00
fiatjaf d4e30356c7 fix: return "message" instead of "error" so it is handled better at the client. 2020-11-10 22:59:50 -03:00
fiatjaf 4456dd6f59 catch stderr when running git subprocess outside of a git repo. 2020-11-10 22:29:52 -03:00
fiatjaf eaec3480e6 lnurl-auth from lnbits wallets to services. 2020-11-10 11:36:10 -03:00
fiatjaf 8f1ae1646e opennode: fix webhook handling (json -> urlencoded data). 2020-11-09 09:38:06 -03:00
benarc d75c69cd10 fixed first opennode bug
Opennode funding source isnt working, there are a few bugs. This fixes the first
2020-11-08 23:15:27 +00:00
fiatjaf 2e12c0ba3c lnurlp: actually fix what the previous commit did not. 2020-11-03 19:47:13 -03:00
fiatjaf 8947358c80 lnurlp: fix ?amount satoshi -> msatoshi conversion. 2020-11-03 19:12:10 -03:00
benarc 091f9b74f3 Fixed LNURL bug 2020-11-02 21:15:47 +00:00
benarc cf3c5d83fe Fixed LNURLw QR bug 2020-11-02 15:52:56 +00:00
fiatjaf 2863653261 lnurlp: accept comments, USD prices, min/max ranges. 2020-10-22 15:58:15 -03:00
fiatjaf 2552fd8fc9 internal payments get reported on async listeners. 2020-10-22 15:36:37 -03:00
fiatjaf a773584f6f tags and extras metadata in payment details dialog. 2020-10-21 09:50:45 -03:00
fiatjaf 85ec111ff0 return proper error codes on /lnurlscan
so the notification works.
2020-10-20 23:19:23 -03:00
fiatjaf 52619ecc0f improve querystring handling on lnurl fallback scheme. 2020-10-18 13:33:05 -03:00
fiatjaf 11e04e4314 fix CSS bundle order issue.
a final fix for https://github.com/lnbits/lnbits/issues/99
2020-10-17 15:24:23 -03:00
fiatjaf 32d75f7a60 support the lnurl fallback scheme.
completing https://github.com/lnbits/lnbits/issues/63
2020-10-17 15:24:10 -03:00
fiatjaf 435f5405c6 minor prettier change. 2020-10-16 14:45:28 -03:00
fiatjaf a46ca45de0 some readme and docs clarification. 2020-10-15 23:12:32 -03:00
fiatjaf 9cb58833ad make httpx calls async when doing them from view functions.
this is important so lnbits can call itself without hanging forever.

and these functions are already async so it should have been this way since ever.
2020-10-15 13:04:56 -03:00
fiatjaf c81e61e4fe guards and safety timeouts around paymentChecker intervals. 2020-10-15 12:32:28 -03:00
fiatjaf bdbb58b2b0 wrap memo columns in payments table. 2020-10-15 00:59:00 -03:00
fiatjaf be7d36214a use payments/sse on the core wallet UI.
still fallback to the invoice polling (now with a 5 seconds interval
because less than that is too annoying).

this fixes issues with /lnurlwallet invoices not getting paid in time,
so we update the UI automatically when they do get paid.

(see https://t.me/lnbits/7069)
2020-10-15 00:18:57 -03:00
fiatjaf f02ec67f35 add lntxbot invoices listener. 2020-10-14 23:58:57 -03:00
fiatjaf d528d4e09e fix: wallet layout. 2020-10-14 23:13:55 -03:00
fiatjaf ec4b558638 fix: domain variable assignment in pay_lnurl. 2020-10-14 22:44:23 -03:00
fiatjaf 0dc60d4795 increase timeouts for http wallets when stuff may take more than 5 seconds.
before we were using requests which had no default timeouts, but httpx has a
default timeout of 5 seconds. should have noticed that earlier.

when the timeout expires we are left with a pending payment on the db with a
temporary checking_id so we can never know if it was completed or not.

this is still an issue, because technically a lightning payment may take 2 weeks
or more, and we must have a way to dispatch a payment and check for it later.

that should be the default (and we already do check for the payment status later,
so half of the work is done), but on the other hand backends like lnpay and
opennode do not give us a checking_id before the thing is already settled.
2020-10-13 20:39:24 -03:00
fiatjafandGitHub 3a56aaa3ad Merge pull request #113 from lnbits/lnurl 2020-10-13 20:31:00 -03:00
fiatjaf e1501e8ed6 fix bug with lnurl-withdraw dialog being skipped directly to the previous invoice shown. 2020-10-13 15:20:58 -03:00
fiatjaf 1529ebb689 lnurlpay comment. 2020-10-13 15:18:34 -03:00
fiatjaf bcdc065cc0 mypy fixes. 2020-10-13 14:46:23 -03:00
fiatjaf cf0bd7ece8 displaying lnurlpay success_actions. 2020-10-13 13:57:26 -03:00
Gabriel Comteandfiatjaf c6bd060d19 docs: fix typo in command 2020-10-13 07:42:01 -03:00
fiatjaf 8d135489ab fix: invoice parsing JS bug from the lnurl implementation. 2020-10-12 23:21:45 -03:00
fiatjaf 69063190ab also catch httpx.ConnectError whenever we do catch httpx.RequestError. 2020-10-12 23:21:45 -03:00
fiatjaf bc2207ba27 actually paying and withdrawing with lnurl. 2020-10-12 23:21:45 -03:00
fiatjaf 3cd15c40fc lnurl-pay and lnurl-withdraw UI. 2020-10-12 23:21:45 -03:00
fiatjaf 7a5159f293 send/create/scan buttons for clear LNURL support. 2020-10-12 23:21:45 -03:00
Arcandfiatjaf fa8713de17 move scan to outside of receive. 2020-10-12 23:21:45 -03:00
fiatjaf b1f30390b0 retrieve preimage from wallet backends, save it and show on UI dialog.
closes https://github.com/lnbits/lnbits/issues/77
2020-10-12 23:19:16 -03:00
fiatjaf f2ea8a0c74 fix lndrest payment checking: b64/hex mismatch.
finally fixes https://github.com/lnbits/lnbits/issues/55
and https://github.com/lnbits/lnbits/issues/110
2020-10-12 23:18:58 -03:00
fiatjaf b5a07c7ae7 add status() method to wallets to be used in initial check. 2020-10-12 22:30:19 -03:00
fiatjaf d5d85d16e6 fix: auth headers variable name on opennode.py 2020-10-12 20:59:09 -03:00
fiatjaf 2894944b1e pending=False on /sse payments. 2020-10-09 22:05:39 -03:00
fiatjaf 31820e041c /sse returns payment as objects instead of arrays. 2020-10-09 21:08:01 -03:00
fiatjaf 1d00060419 harden sse connections to backends a little. 2020-10-09 20:55:58 -03:00
fiatjaf 7f41d73a84 SSE-based invoices stream for LNbitsWallet. 2020-10-09 17:06:50 -03:00
fiatjaf f638a1088e lnurlp: fix querystring handling in success_url. 2020-10-09 16:40:49 -03:00
fiatjaf ea3418c21d lnurlp: support success_text and success_url. 2020-10-09 16:17:16 -03:00
fiatjaf f6bcff01f4 presort vendored paths list.
an attempt to fix https://github.com/lnbits/lnbits/issues/99.
2020-10-08 22:10:21 -03:00
fiatjaf 33fa540744 adjust docs to match 91853 2020-10-08 21:51:40 -03:00
fiatjaf 6f72f1fe6d fix typo: LND_MACAROON -> LND_REST_MACAROON 2020-10-08 16:24:23 -03:00
fiatjaf 76633fb71f lndrest: check last 20 payments instead of first 20.
fixes https://github.com/lnbits/lnbits/issues/110
2020-10-08 16:08:13 -03:00
fiatjaf 9185342c72 simplify environment variables required.
instead of multiple keys/macaroons with different permissions we request only one.
if someone wants to use lnbits with an invoice macaroon they're free to do it and
we will just fail on 'pay' methods, as before.

this also grandfathers the previous environment variables names so everything keeps
working without people having to change their setups.

in the meantime some bugs with lntxbot and c-lightning were fixed and the `requests`
dependency was eliminated because I can't organize myself into meaningful chunks of
changes.
2020-10-08 16:03:21 -03:00
HamishMacEwanandfiatjaf fc6e49b821 Change .env variable names to match lnbits.py
The required values are expected in LNBITS_INVOICE_KEY & LNBITS_ADMIN_KEY according to https://github.com/lnbits/lnbits/blob/master/lnbits/wallets/lnbits.py
2020-10-07 01:27:16 -03:00
fiatjaf 35f8e3334c fix typo: LnbitsWallet -> LNbitsWallet. 2020-10-06 23:04:25 -03:00
fiatjaf 0b1f3c22e0 lndhub: fix "token" -> "refresh_token". 2020-10-06 21:53:43 -03:00
fiatjaf 24c8201920 add current git commit to summary. 2020-10-06 17:42:33 -03:00
fiatjaf 9a16dfa960 remove annoying warnings and unnecessary prints.
show a summary of some settings on startup when running __main__.py
2020-10-06 12:59:08 -03:00
fiatjaf c5352c0309 register channel listeners instead of callbacks.
makes for a little less black magic and more reasonable use of nurseries
and less unnecessary pseudo-requests.
2020-10-06 01:50:56 -03:00
fiatjaf 95e8573ff8 SSE endpoint for paid invoices.
also move very essential stuff from core/tasks.py to tasks.py so things
are more organized.
2020-10-06 00:40:18 -03:00
fiatjaf 4e68a0e7e6 lndrest: two small fixes. 2020-10-05 22:43:49 -03:00
fiatjaf 16541d40c6 show more data in txinfo modal and fee on main table.
- also center-align icons in main table.
- and use "x time ago" instead of raw dates by default in main table
  (raw date is accessible on a tooltip, on the modal and on the CSV export).
2020-10-05 22:38:06 -03:00
fiatjaf bb965e4119 lndrest: account for when there are pending checking_ids from other sources. 2020-10-05 22:16:28 -03:00
fiatjaf 5ead82f57c fix /lnurlwallet again. db.commit() and wait 3 seconds.
should finally fix https://github.com/lnbits/lnbits/issues/103
2020-10-05 20:33:01 -03:00
fiatjaf 1eac618a25 lnurlp: allow creation of pay_links without webhook_url. 2020-10-05 20:12:24 -03:00
fiatjafandGitHub e1744caec5 Merge pull request #101 from lnbits/invoice-listeners 2020-10-05 13:40:31 -03:00
fiatjaf 39c331a7b9 fix opennode stupid syntax errors. 2020-10-05 08:46:20 -03:00
fiatjaf 6e6ce093f0 guard against lndrest URLs without a leading http... 2020-10-05 08:45:57 -03:00
fiatjaf 1ce9936561 better handling of missing dependencies on lndgrpc. 2020-10-05 08:45:32 -03:00
fiatjaf e408febf97 hypercorn -k trio 2020-10-04 23:09:11 -03:00
fiatjaf c3ded859df fix requirements after rebase. 2020-10-04 14:07:52 -03:00
fiatjaf f5b8ed8fc6 make lndgrpc work using the purerpc library and a manually-declared method. 2020-10-04 12:06:54 -03:00
fiatjaf 9994e61615 migrate to trio so c-lightning sockets stop hanging. 2020-10-04 12:06:20 -03:00
fiatjaf e74cf33f90 broken invoice listener on c-lightning and other fixes around wallets. 2020-10-04 12:05:59 -03:00
fiatjaf b3c69ad49c invoice listeners support on lnd and other fixes around wallets/ 2020-10-04 12:05:01 -03:00
fiatjaf 90c640b659 fix async so now it is a mess that works. 2020-10-04 12:05:01 -03:00
fiatjaf 28e6f40bea async fixes on _invoice_listener. 2020-10-04 12:05:01 -03:00
fiatjaf bbe8d05af4 migrate from aiohttp to httpx following master. 2020-10-04 12:04:59 -03:00
fiatjaf 2c92205703 async invoice listeners through webhooks: lnpay and opennode. 2020-10-04 12:04:49 -03:00
fiatjaf 74117ffc57 lnurlp webhooks. 2020-10-04 12:04:49 -03:00
fiatjaf 04222f1f01 basic invoice listeners. 2020-10-04 12:04:47 -03:00
fiatjaf e2f6c20e3b update docs so master is runnable. 2020-10-02 14:59:29 -03:00
fiatjaf 08b2ce5c05 update requirements.txt 2020-10-02 08:40:50 -03:00
benarc 5971eefe44 Fixed register page blank bug 2020-10-01 19:44:04 +01:00
benarc a11b967ffc Merge remote-tracking branch 'origin/master' 2020-10-01 19:40:16 +01:00
ArcandGitHub f6e6bf73d3 Pointed people towards a stable release 2020-10-01 18:12:29 +01:00
benarc f2619a4bbc piplockupdtae 2020-09-30 19:22:19 +01:00
fiatjaf 88b8826bfd fix balance calculation vue glitch. 2020-09-29 20:04:02 -03:00
fiatjaf 211ac0391b overengineered async fix for /lnurlwallet internal hanging. 2020-09-29 18:24:08 -03:00
fiatjaf e0b8470d40 show unpaid invoices, balance calculated on server-side so isn't affected. 2020-09-29 15:43:11 -03:00
fiatjaf 2dfae9ecc1 adapt Procfile hypercorn call. 2020-09-28 21:11:34 -03:00
Eneko Illarramendi 3ab29e6e50 fix: lnbits.com deploy 2020-09-28 23:49:11 +02:00
EnekoandGitHub f60cebb072 fix: add missing assets command (#100) 2020-09-28 23:11:59 +02:00
benarc d8da88aefa Fixed QR codes not printing 2020-09-28 16:59:20 +01:00
fiatjaf 49baa07141 fix proxyfix. 2020-09-28 00:21:53 -03:00
Eneko Illarramendiandfiatjaf 098089af75 PREFER_SECURE_URLS, get rid of FORCE_HTTPS hacks. 2020-09-24 13:08:59 -03:00
fiatjaf 39cd96594e pass appropriate parameters to create_payment on lnurlwallet. 2020-09-24 13:09:02 -03:00
fiatjafandGitHub dbabf937c4 Merge pull request #93 from lnbits/quart 2020-09-17 22:51:19 -03:00
fiatjaf cd8a447ba0 pytest quibbles and dev dependencies. 2020-09-17 22:41:30 -03:00
fiatjaf ef0bdf8363 mypy quibbles. 2020-09-16 20:24:17 -03:00
fiatjaf 75047c5bc3 fix static extensions js url. 2020-09-16 20:21:42 -03:00
fiatjaf 2b38207100 mimic x_proto, x_host params from old ProxyFix. 2020-09-15 17:19:34 -03:00
fiatjaf 5513b34a66 change 1 to true on env vars because that is supported now. 2020-09-15 17:19:11 -03:00
fiatjaf 6928f431a7 poor man's flask-assets. 2020-09-15 15:54:05 -03:00
fiatjaf 3a0762ff82 proxy fix (x-forwarded-...). 2020-09-14 23:56:25 -03:00
fiatjaf 42c6620a07 quart run settings and hypercorn on docker/Procfile. 2020-09-14 23:28:14 -03:00
fiatjaf f01028eac7 migrate from flask to quart.
also remove all flaskiness from static file serving.
and reference all vendored scripts on the base tempĺate for simplicity.
2020-09-14 16:03:25 -03:00
fiatjaf f452b9c00d withdraw: replace location.hostname with location.host.
fixes https://github.com/lnbits/lnbits/issues/88
2020-09-14 15:46:25 -03:00
fiatjaf 1e2dd412c7 lnurlp: remove wrong /pay/ segments from docs. 2020-09-14 09:35:58 -03:00
fiatjaf e6ad42dbe1 update prettier to 2.1.1 2020-09-14 09:14:51 -03:00
fiatjaf 1faf0b152c remove annoying print(). 2020-09-13 21:48:46 -03:00
fiatjafandfiatjaf f4fffc19aa lndhub extension frontend. 2020-09-12 21:55:13 -03:00
fiatjafandfiatjaf 6210305791 checking pending invoices and payments from lndhub interface. 2020-09-12 21:55:13 -03:00
fiatjafandfiatjaf ce038193cf lndhub extension backend. 2020-09-12 21:55:13 -03:00
fiatjaf 5ac91ee2cf db commits at the right places when sending payments.
fixing vulnerabilities introduced in https://github.com/lnbits/lnbits/pull/90
2020-09-11 21:25:44 -03:00
fiatjafandfiatjaf 4855e2cd3d make every request a database transaction. 2020-09-11 14:17:09 -03:00
fiatjafandfiatjaf 487f082eb2 take extra care on check_internal() 2020-09-11 14:17:09 -03:00
frennkieandGitHub a8e1aa3154 Consider port number on lnurlp URL
fix #86
2020-09-09 21:43:34 -03:00
ArcandGitHub afb2cc4339 Update .env.example 2020-09-08 09:55:31 +01:00
ArcandGitHub 6b94abcfb9 Moved install guide to more prominent position 2020-09-08 09:30:23 +01:00
supertestnetandGitHub bd48bdc04d Update .env.example due to recent changes (#91)
The name of the flask app has changed and the word "all" has been eliminated as unnecessary because allowing anyone to use lnbits is the default.
2020-09-07 16:05:50 +02:00
fiatjaf 130beaf38a fix undeclared ok error. 2020-09-06 23:39:46 -03:00
fiatjaf ab6229806c fix: column is called hash, not payment_hash. 2020-09-06 22:15:29 -03:00
Eneko Illarramendiandfiatjaf d62031b068 chore: let's just call it flask_migrate
I think it should stay, because it can come handy for some deployment methods.
2020-09-06 21:06:01 -03:00
Eneko Illarramendiandfiatjaf 1bc5e144d3 refactor: clean up __init__ file following some Flask conventions
Flask extensions are loaded in a way that makes them easily reusable by blueprints.
In this commit we are also adding `environs` to manage .env and settings:
breaking changes!

- FLASK_APP=lnbits.app
- LNBITS_ALLOWED_USERS needs to be empty now to allow all users (NOT "all")
2020-09-06 21:06:01 -03:00
fiatjafandfiatjaf ffa3c3f6a6 python -m lnbits 2020-09-04 21:33:30 -03:00
fiatjafandfiatjaf b8de30423f remove walrus operator because python3.7 2020-09-04 21:33:30 -03:00
fiatjafandfiatjaf c965bca41d migration tracking. 2020-09-04 21:33:30 -03:00
Eneko Illarramendi f238b3d1ef fix: TypedDict import 2020-09-04 21:24:30 +02:00
Eneko Illarramendi 6b33f150c6 fix: revert default python version to 3.7 in Pipfile, closes #85
But tests will be run on both 3.7 and 3.8 as we want to support >= 3.7
2020-09-04 21:19:06 +02:00
fiatjaf 8b7028d728 add VoidWallet and make it the default. 2020-09-03 21:43:32 -03:00
Eneko Illarramendi 23cfe0d417 chore: update github workflows 2020-09-03 23:10:41 +02:00
Eneko Illarramendi a651f747ac chore: remove gunicorn and gevent from minimum requirements
It is now up to the user to install these (or other) for production deployment.
More info: https://flask.palletsprojects.com/en/1.1.x/tutorial/deploy/#run-with-a-production-server
2020-09-03 19:38:01 +02:00
fiatjaf dcd3f80974 fix: invoice.payee is None by default. 2020-09-03 08:53:17 -03:00
fiatjafandGitHub 6513908a8d Merge pull request #81 from lnbits/internalpaymentsmegachanges 2020-09-03 00:29:20 -03:00
fiatjaf 753e9c5b15 make linting workflow reflect what is done locally (Makefile). 2020-09-02 23:16:23 -03:00
fiatjaf dc3d96c6a8 fix many mypy complaints, specially on bolt11.py 2020-09-02 22:21:56 -03:00
fiatjaf ce28db76c9 add a dialog with payment details for each payment.
for outgoing payments this needs a preimage to be good,
but we don't have it yet because we don't get it from
backends.
2020-09-02 19:19:19 -03:00
fiatjaf 197af922d0 use {"tag": ext} for extension-related payments. 2020-09-02 12:44:54 -03:00
fiatjaf 4447a48724 deleting expired invoices based on their actual expiry date.
also fixes a possible bug that could have caused pending outgoing
payments to be deleted and affecting the balance. probably never happened.
2020-09-02 11:10:48 -03:00
fiatjaf d2650d6e2c don't try to check payments that start with temp_ or internal_. 2020-09-02 11:10:48 -03:00
fiatjaf b56877d470 make db.py context manager operate in a transaction. 2020-09-02 11:10:48 -03:00
fiatjaf bf3c44b3c4 extra fields on apipayments + index payments by payment_hash 2020-09-02 11:10:48 -03:00
arcbtcandfiatjaf c96b22664e internal payments. 2020-09-02 11:10:48 -03:00
Eneko Illarramendi d4f957a5c8 chore: update js vendors 2020-09-02 14:39:20 +02:00
Eneko Illarramendi 0d0e00e699 chore: update Pipfile 2020-09-02 14:31:44 +02:00
fiatjaf b4a6b45645 only check pending payments on "check_pending". 2020-08-31 22:55:19 -03:00
fiatjaf fadddc995a get_wallet_payments with more fine-grained, explicit filters. 2020-08-31 22:16:19 -03:00
fiatjaf d09e624eb6 fix: wallet.balance computed property self-reference. 2020-08-31 16:19:27 -03:00
fiatjaf 5d523519c3 bring back code mysteriously deleted in 79d5befe0c
fixes https://github.com/lnbits/lnbits/issues/73
2020-08-31 00:09:34 -03:00
fiatjaf 68b0adfe66 fix: description_hash as an optional param to create_invoice.
fixes https://github.com/lnbits/lnbits/issues/74
2020-08-30 23:54:50 -03:00
fiatjaf 660d56d400 run black on everything. 2020-08-30 23:19:43 -03:00
fiatjaf 2cecaa229b bolt11.py now supports everything. 2020-08-30 17:40:28 -03:00
fiatjaf ac20e06dc3 fix: correct db.cursor() usage on lnurlp. 2020-08-29 22:33:01 -03:00
fiatjaf 79d5befe0c mypy on Makefile and apply prettier. 2020-08-29 14:10:26 -03:00
fiatjafandGitHub 479760c5a5 Merge pull request #43 from fiatjaf/lnurlpayserver
description_hash support, spark backend and lnurlp extension.
2020-08-29 14:02:59 -03:00
fiatjaf 3ce1e72e10 lnurlp lnurl explanation slightly different from lnurlw. 2020-08-29 14:06:07 -03:00
fiatjaf 1e4f39954e fix: spark create_invoice error case. 2020-08-29 13:48:16 -03:00
fiatjaf 01b19add79 format: prettier lnurlp files. 2020-08-29 13:41:59 -03:00
fiatjaf 96b80fb3fc lnurlp: store description as memo. 2020-08-29 13:11:19 -03:00
fiatjaf 3645e0ac38 fix: lnpay invoices with description_hash. 2020-08-29 13:04:11 -03:00
fiatjaf 0c7284be31 description_hash fix. 2020-08-29 12:23:17 -03:00
fiatjaf 0de08dda0b add spark backend and fix c-lightning. 2020-08-29 12:23:01 -03:00
fiatjaf a79ec8f4a4 lnurlp extension. 2020-08-28 23:03:12 -03:00
fiatjaf 24dc6e5415 fix validation rules so description_hash works. 2020-07-04 22:28:08 -03:00
fiatjaf bc27293315 support description_hash across all APIs. 2020-07-03 20:04:04 -03:00
185 changed files with 12856 additions and 4896 deletions
+36 -26
View File
@@ -1,46 +1,56 @@
FLASK_APP=lnbits
FLASK_ENV=development
QUART_APP=lnbits.app:create_app()
QUART_ENV=development
QUART_DEBUG=true
HOST=127.0.0.1
PORT=5000
LNBITS_SITE_TITLE=LNbits
LNBITS_ALLOWED_USERS="all"
LNBITS_ALLOWED_USERS=""
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
LNBITS_DATA_FOLDER="/your_custom_data_folder"
LNBITS_DISABLED_EXTENSIONS="amilk,events"
LNBITS_FORCE_HTTPS=1
LNBITS_DATA_FOLDER="."
LNBITS_DISABLED_EXTENSIONS="amilk"
LNBITS_FORCE_HTTPS=true
LNBITS_SERVICE_FEE="0.0"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC), LndRestWallet, CLightningWallet, LnbitsWallet
LNBITS_BACKEND_WALLET_CLASS=LntxbotWallet
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC),
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file.
# Set one of these blocks depending on the wallet kind you chose above:
# SparkWallet
SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken
# CLightningWallet
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
LNBITS_ENDPOINT=127.0.0.1:5000
LNBITS_INVOICE_MACAROON=LNBITS_INVOICE_MACAROON
LNBITS_ADMIN_MACAROON=LNBITS_ADMIN_MACAROON
# LnbitsWallet
LNBITS_ENDPOINT=http://127.0.0.1:5000
LNBITS_KEY=LNBITS_ADMIN_KEY
# LndWallet
LND_GRPC_ENDPOINT=127.0.0.1
LND_GRPC_PORT=11009
LND_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_ADMIN_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon"
LND_INVOICE_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/invoice.macaroon"
LND_READ_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/read.macaroon"
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon"
LND_REST_ENDPOINT=https://localhost:8080/
# LndRestWallet
LND_REST_ENDPOINT=https://127.0.0.1:8080/
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_REST_ADMIN_MACAROON="HEXSTRING"
LND_REST_INVOICE_MACAROON="HEXSTRING"
LND_REST_READ_MACAROON="HEXSTRING"
LND_REST_MACAROON="HEXSTRING"
# LNPayWallet
LNPAY_API_ENDPOINT=https://lnpay.co/v1/
LNPAY_API_KEY=LNPAY_API_KEY
LNPAY_ADMIN_KEY=LNPAY_ADMIN_KEY
LNPAY_INVOICE_KEY=LNPAY_INVOICE_KEY
LNPAY_READ_KEY=LNPAY_READ_KEY
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
# LntxbotWallet
LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/
LNTXBOT_ADMIN_KEY=LNTXBOT_ADMIN_KEY
LNTXBOT_INVOICE_KEY=LNTXBOT_INVOICE_KEY
LNTXBOT_KEY=LNTXBOT_ADMIN_KEY
# OpenNodeWallet
OPENNODE_API_ENDPOINT=https://api.opennode.com/
OPENNODE_ADMIN_KEY=OPENNODE_ADMIN_KEY
OPENNODE_INVOICE_KEY=OPENNODE_INVOICE_KEY
OPENNODE_KEY=OPENNODE_ADMIN_KEY
-1
View File
@@ -1 +0,0 @@
**/static/vendor/** linguist-vendored
+16 -42
View File
@@ -1,54 +1,28 @@
name: "CodeQL"
name: codeql
on:
push:
branches: [master, ]
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: '0 12 * * 5'
jobs:
CodeQL-Build:
analyze:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
# Override language selection by uncommenting this and choosing your languages
# with:
# languages: go, javascript, csharp, python, cpp, java
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 2
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: javascript, python
- name: Autobuild
uses: github/codeql-action/autobuild@v1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
+23
View File
@@ -0,0 +1,23 @@
name: formatting
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
black:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: sudo apt-get install python3-venv
- run: python3 -m venv venv
- run: ./venv/bin/pip install black
- run: make checkblack
prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: npm install
- run: make checkprettier
-25
View File
@@ -1,25 +0,0 @@
name: Run Linters
on: [push, pull_request]
jobs:
mypy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Run MyPy python type checker
uses: jpetrucciani/mypy-check@master
with:
path: 'lnbits'
prettier:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Check JS code formatting convention
uses: creyD/prettier_action@v2.2
with:
dry: True
prettier_options: --write lnbits/static/js/** lnbits/core/static/js/** lnbits/extensions/*/templates/**
+12
View File
@@ -0,0 +1,12 @@
name: mypy
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: jpetrucciani/mypy-check@master
with:
path: lnbits
+2 -4
View File
@@ -1,15 +1,13 @@
name: test suite
name: tests
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8]
python-version: [3.7, 3.8]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
+2
View File
@@ -21,6 +21,7 @@ test-reports
*.pyc
*.env
.env
.venv
venv
*.sqlite3
@@ -29,3 +30,4 @@ venv
__bundle__
node_modules
lnbits/static/bundle.*
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.7
FROM python:3.7-slim
WORKDIR /app
COPY requirements.txt /app/
+23 -2
View File
@@ -1,2 +1,23 @@
prettier:
./node_modules/.bin/prettier --write lnbits/static/js/** lnbits/core/static/js/** lnbits/extensions/*/templates/**
all: format check requirements.txt
format: prettier black
check: mypy checkprettier checkblack
prettier: $(shell find lnbits -name "*.js" -name ".html")
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
black: $(shell find lnbits -name "*.py")
./venv/bin/black lnbits
mypy: $(shell find lnbits -name "*.py")
./venv/bin/mypy lnbits
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
checkblack: $(shell find lnbits -name "*.py")
./venv/bin/black --check lnbits
requirements.txt: Pipfile.lock
cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt
+14 -11
View File
@@ -9,21 +9,24 @@ python_version = "3.7"
[packages]
bitstring = "*"
cerberus = "*"
ecdsa = "*"
environs = "*"
lnurl = "*"
flask = "*"
flask-assets = "*"
flask-compress = "*"
flask-cors = "*"
flask-talisman = "*"
gevent = "==20.5.0"
gunicorn = "*"
pyscss = "*"
requests = "*"
shortuuid = "*"
quart = "*"
quart-cors = "*"
quart-compress = "*"
secure = "*"
typing-extensions = "*"
httpx = "*"
quart-trio = "*"
trio = "*"
hypercorn = {extras = ["trio"], version = "*"}
[dev-packages]
black = "==19.10b0"
flake8 = "*"
flake8-mypy = "*"
black = "==20.8b1"
pytest = "*"
pytest-cov = "*"
mypy = "==0.761"
pytest-trio = "*"
Generated
+377 -295
View File
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "76587c919e4a0d49f3a4ff297353ec8b8c92521effdb5eaf985cb32213ebc5c5"
"sha256": "193ef930ac0906127fd292b2fc7ba5b4d786227b6795d182dad9b57448fca75e"
},
"pipfile-spec": 6,
"requires": {
@@ -16,6 +16,29 @@
]
},
"default": {
"aiofiles": {
"hashes": [
"sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb",
"sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af"
],
"version": "==0.5.0"
},
"async-generator": {
"hashes": [
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
],
"markers": "python_version >= '3.5'",
"version": "==1.10"
},
"attrs": {
"hashes": [
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
"sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.2.0"
},
"bech32": {
"hashes": [
"sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899",
@@ -31,43 +54,43 @@
"index": "pypi",
"version": "==3.1.7"
},
"blinker": {
"hashes": [
"sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"
],
"version": "==1.4"
},
"brotli": {
"hashes": [
"sha256:0538dc1744fd17c314d2adc409ea7d1b779783b89fd95bcfb0c2acc93a6ea5a7",
"sha256:0970a47f471782912d7705160b2b0a9306e68e6fadf9cffcaeb42d8f0951e26c",
"sha256:113f51658e6fe548dce4b3749f6ef6c24de4184ba9c10a909cbee4261c2a5da0",
"sha256:1e1aa9c4d1558889f42749c8baf846007953bfd32c8209230cf1cd1f5ef33495",
"sha256:2f2f4f78f29ac4a45d15b3d9fc3fd9705e0ad313a44b129f6e1d0c6916bad0e2",
"sha256:3269f6de1dd150fd0cce1c158b61ff5ac06d627fd3ae9c6ea03aed26fbbff7ea",
"sha256:3f4a1f6240916c7984c7f2542786710f622992508dafee0b1714e6d340fb9ffd",
"sha256:50dd9ad2a2bb12da4e9002a438672d182f98e546e99952de80280a1e1729664f",
"sha256:5519a4b01b1a4f965083cbfa2ef2b9774c5a5f352341c47b50776ad109423d72",
"sha256:5eb27722d320370315971c427eb8aa7cc0791f2a458840d357ac653bd0ad3a14",
"sha256:5f06b4d5b6f58e5b5c220c2f23cad034dc5efa51b01fde2351ced1605bd980e2",
"sha256:71ceee286ea7ec613f1c36f1c6181864a6ca24ebb55e371276f33d6af8742834",
"sha256:72848d25a5f9e736db4af4512e0c3feecc094d57d241f8f1ae959115a2c39756",
"sha256:743001bca75f4a6b4454be3510feca46f9d61a0c782a9bc2bc684bdb245e279e",
"sha256:7ac98c71a15648fd11bc1f32608b6110e396121280790082e32b9a3109048bc6",
"sha256:9d1c2dd27a1083fefd05b1b2f8df4a6bc2aaa6c21dd82cd41c8ae5e7c23a87f8",
"sha256:a13ce9b419fe9f277c63f700efb0e444331509d1881b5610d2ba7e9080606967",
"sha256:a19ef0952b9d2803df88dff07f45a6c92d5676afb9b8d69cf32232d684036d11",
"sha256:ad766ca8b8c1419b71a22756b45264f45725c86133dc80a7cbe30b6b78c75620",
"sha256:ad7963f261988ee0883816b6b9f206f11461c9b3cb5cfbca0c9ab5adc406d395",
"sha256:af0451e23016631a2f52925a10d738ac4a0f794ac315c30380b22efc0c90cbc6",
"sha256:c16201060c5a3f8742e3deae759014251ac92f382f82bc2a41dc079ff18c3f24",
"sha256:c43b202f65891861a9a336984a103de25de235f756de69e32db893156f767013",
"sha256:c675c6cce4295cb1a692f3de7416aacace7314e064b94bc86e93aceefce7fd3e",
"sha256:d17cec0b992b1434f5f9df9986563605a4d1b1acd5574c87fc2ac014bcbd3316",
"sha256:dc91f6129953861a73d9a65c52a8dd682b561a9ebaf65283541645cab6489917",
"sha256:e2f4cbd1760d2bf2f30e396c2301999aab0191aec031a6a8a04950b2f575a536",
"sha256:f192e6d3556714105c10486bbd6d045e38a0c04d9da3cef21e0a8dfd8e162df4",
"sha256:f775b07026af2b1b0b5a8b05e41571cdcf3a315a67df265d60af301656a5425b",
"sha256:f969ec7f56ba9636679e69ca07fba548312ccaca37412ee823c7f413541ad7e0",
"sha256:f9dc52cd70907aafb99a773b66b156f2f995c7a0d284397c487c8b71ddbef2f9",
"sha256:f9ee88bb52352588ceb811d045b5c9bb1dc38927bc150fd156244f60ff3f59f1",
"sha256:fc7212e36ebeb81aebf7949c92897b622490d7c0e333a479c0395591e7994600"
"sha256:160c78292e98d21e73a4cc7f76a234390e516afcd982fa17e1422f7c6a9ce9c8",
"sha256:1c48472a6ba3b113452355b9af0a60da5c2ae60477f8feda8346f8fd48e3e87c",
"sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70",
"sha256:269a5743a393c65db46a7bb982644c67ecba4b8d91b392403ad8a861ba6f495f",
"sha256:35a3edbe18e876e596553c4007a087f8bcfd538f19bc116917b3c7522fca0429",
"sha256:3b78a24b5fd13c03ee2b7b86290ed20efdc95da75a3557cc06811764d5ad1126",
"sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4",
"sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438",
"sha256:503fa6af7da9f4b5780bb7e4cbe0c639b010f12be85d02c99452825dd0feef3f",
"sha256:5913a1177fc36e30fcf6dc868ce23b0453952c78c04c266d3149b3d39e1410d6",
"sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26",
"sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14",
"sha256:68715970f16b6e92c574c30747c95cf8cf62804569647386ff032195dc89a430",
"sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296",
"sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12",
"sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452",
"sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea",
"sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a",
"sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5",
"sha256:9d12cf2851759b8de8ca5fde36a59c08210a97ffca0eb94c532ce7b17c6a3d1d",
"sha256:afde17ae04d90fbe53afb628f7f2d4ca022797aa093e809de5c3cf276f61bbfa",
"sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb",
"sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b",
"sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4",
"sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7",
"sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1",
"sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1"
],
"version": "==1.0.7"
"version": "==1.0.9"
},
"cerberus": {
"hashes": [
@@ -83,13 +106,6 @@
],
"version": "==2020.6.20"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
@@ -98,110 +114,85 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==7.1.2"
},
"flask": {
"ecdsa": {
"hashes": [
"sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060",
"sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"
"sha256:494c6a853e9ed2e9be33d160b41d47afc50a6629b993d2b9c5ad7bb226add892",
"sha256:ca359c971594dceebf334f3d623dae43163ab161c7d09f28cae70a86df26eb7a"
],
"index": "pypi",
"version": "==1.1.2"
"version": "==0.16.0"
},
"flask-assets": {
"environs": {
"hashes": [
"sha256:1dfdea35e40744d46aada72831f7613d67bf38e8b20ccaaa9e91fdc37aa3b8c2",
"sha256:2845bd3b479be9db8556801e7ebc2746ce2d9edb4e7b64a1c786ecbfc1e5867b"
"sha256:a98005aab7613b6fe7a1af7192a5163f72a52d3348d3918e6c7a2a32e4012779",
"sha256:bf3fd6bc54fcfd7f512ddcb80a7781f0ced2b0c83dd123d619e9468ecdaaf537"
],
"index": "pypi",
"version": "==2.0"
"version": "==8.0.0"
},
"flask-compress": {
"h11": {
"hashes": [
"sha256:f367b2b46003dd62be34f7fb1379938032656dca56377a9bc90e7188e4289a7c"
"sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab",
"sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87"
],
"version": "==0.11.0"
},
"h2": {
"hashes": [
"sha256:ac9e293a1990b339d5d71b19c5fe630e3dd4d768c620d1730d355485323f1b25",
"sha256:bb7ac7099dd67a857ed52c815a6192b6b1f5ba6b516237fc24a085341340593d"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==4.0.0"
},
"hpack": {
"hashes": [
"sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c",
"sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==4.0.0"
},
"httpcore": {
"hashes": [
"sha256:18c4afcbfe884b635e59739105aed1692e132bc5d31597109f3c1c97e4ec1cac",
"sha256:2526a38f31ac5967d38b7f593b5d8c4bd3fa82c21400402f866ba3312946acbf"
],
"markers": "python_version >= '3.6'",
"version": "==0.12.0"
},
"httpx": {
"hashes": [
"sha256:126424c279c842738805974687e0518a94c7ae8d140cd65b9c4f77ac46ffa537",
"sha256:9cffb8ba31fac6536f2c8cde30df859013f59e4bcc5b8d43901cb3654a8e0a5b"
],
"index": "pypi",
"version": "==1.5.0"
"version": "==0.16.1"
},
"flask-cors": {
"hypercorn": {
"extras": [
"trio"
],
"hashes": [
"sha256:72170423eb4612f0847318afff8c247b38bd516b7737adfc10d1c2cdbb382d16",
"sha256:f4d97201660e6bbcff2d89d082b5b6d31abee04b1b3003ee073a6fd25ad1d69a"
"sha256:81c69dd84a87b8e8b3ebf06ef5dd92836a8238f0ac65ded3d86befb8ba9acfeb",
"sha256:e3f317d6d64d15ce589f49e4f5057947259fa35332d169e62cb060e9997189e4"
],
"index": "pypi",
"version": "==3.0.8"
"version": "==0.11.1"
},
"flask-talisman": {
"hyperframe": {
"hashes": [
"sha256:468131464a249274ed226efc21b372518f442487e58918ccab8357eaa638fd1f",
"sha256:eaa754f4b771dfbe473843391d69643b79e3a38c865790011ac5e4179c68e3ec"
"sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1",
"sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34"
],
"index": "pypi",
"version": "==0.7.0"
},
"gevent": {
"hashes": [
"sha256:00b03601b8dd1ee2aa07811cb60a4befe36173b15d91c6e207e37f8d77dd6fac",
"sha256:0acc15ba2ac2a555529ad82d5a28fc85dbb6b2ff947657d67bebfd352e2b5c14",
"sha256:15eae3cd450dac7dae7f4ac59e01db1378965c9ef565c39c5ae78c5a888f9ac9",
"sha256:1dc7f1f6bc1f67d625e4272b01e717eba0b4fa024d2ff7934c8d320674d6f7fa",
"sha256:1dd95433be45e1115053878366e3f5332ae99c39cb345be23851327c062b9f4a",
"sha256:28b7d83b4327ceb79668eca2049bf4b9ce66d5ace18a88335e3035b573f889fd",
"sha256:31dc5d4ab8172cc00c4ff17cb18edee633babd961f64bf54214244d769bc3a74",
"sha256:38db524ea88d81d596b2cbb6948fced26654a15fec40ea4529224e239a6f45e8",
"sha256:3ff477b6d275396123faf8ce2d5b82f96d85ba264e0b9d4b56a2bac49d1b9adc",
"sha256:4d2729dd4bf9c4d0f29482f53cdf9fc90a498aebb5cd7ae8b45d35657437d2ac",
"sha256:52e5cd607749ed3b8aa0272cacf2c11deec61fca4c3bec57a9fea8c49316627d",
"sha256:5c604179cebcc57f10505d8db177b92a715907815a464b066e7eba322d1c33ac",
"sha256:88c76df4967c5229f853aa67ad1b394d9e4f985b0359c9bc9879416bba3e7c68",
"sha256:929c33df8e9bcbe31906024fcd21580bd018196dbd3249eb5b2f19d63e11092d",
"sha256:92edc18a357473e01a4e4a82c073ed3c99ceca6e3ce93c23668dd4a2401f07dc",
"sha256:937d36730f2b0dee3387712074b1f15b802e2e074a3d7c6dcaf70521236d607c",
"sha256:9b4e940fc6071afebb86ba5f48dbb5f1fc3cb96ebeb8cf145eb5b499e9c6ee33",
"sha256:a7805934e8ce81610b61f806572c3d504cedd698cc8c9460d78d2893ba598c4a",
"sha256:d07a2afe4215731eb57d5b257a2e7e7e170d8a7ae1f02f6d0682cd3403debea9",
"sha256:e01d5373528e4ebdde66dc47a608d225fa3c4408ccd828d26c49b7ff75d82bd9",
"sha256:efd9546468502a30ddd4699c3124ccb9d3099130f9b5ae1e2a54ad5b46e86120",
"sha256:fcb64f3a28420d1b872b7ef41b12e8a1a4dcadfc8eff3c09993ab0cdf52584a1"
],
"index": "pypi",
"version": "==20.5.0"
},
"greenlet": {
"hashes": [
"sha256:1000038ba0ea9032948e2156a9c15f5686f36945e8f9906e6b8db49f358e7b52",
"sha256:133ba06bad4e5f2f8bf6a0ac434e0fd686df749a86b3478903b92ec3a9c0c90b",
"sha256:1429dc183b36ec972055e13250d96e174491559433eb3061691b446899b87384",
"sha256:1b805231bfb7b2900a16638c3c8b45c694334c811f84463e52451e00c9412691",
"sha256:3a35e33902b2e6079949feed7a2dafa5ac6f019da97bd255842bb22de3c11bf5",
"sha256:5ea034d040e6ab1d2ae04ab05a3f37dbd719c4dee3804b13903d4cc794b1336e",
"sha256:682328aa576ec393c1872615bcb877cf32d800d4a2f150e1a5dc7e56644010b1",
"sha256:6e06eac722676797e8fce4adb8ad3dc57a1bb3adfb0dd3fdf8306c055a38456c",
"sha256:7eed31f4efc8356e200568ba05ad645525f1fbd8674f1e5be61a493e715e3873",
"sha256:80cb0380838bf4e48da6adedb0c7cd060c187bb4a75f67a5aa9ec33689b84872",
"sha256:b0b2a984bbfc543d144d88caad6cc7ff4a71be77102014bd617bd88cfb038727",
"sha256:c196a5394c56352e21cb7224739c6dd0075b69dd56f758505951d1d8d68cf8a9",
"sha256:d83c1d38658b0f81c282b41238092ed89d8f93c6e342224ab73fb39e16848721",
"sha256:df7de669cbf21de4b04a3ffc9920bc8426cab4c61365fa84d79bf97401a8bef7",
"sha256:e5db19d4a7d41bbeb3dd89b49fc1bc7e6e515b51bbf32589c618655a0ebe0bf0",
"sha256:e695ac8c3efe124d998230b219eb51afb6ef10524a50b3c45109c4b77a8a3a92",
"sha256:eac2a3f659d5f41d6bbfb6a97733bc7800ea5e906dc873732e00cebb98cec9e4"
],
"markers": "platform_python_implementation == 'CPython'",
"version": "==0.4.16"
},
"gunicorn": {
"hashes": [
"sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
"sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
],
"index": "pypi",
"version": "==20.0.4"
"markers": "python_full_version >= '3.6.1'",
"version": "==6.0.0"
},
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.10"
},
"itsdangerous": {
@@ -222,10 +213,10 @@
},
"lnurl": {
"hashes": [
"sha256:398fcb58b8112575bb24a445c61dda0e180df232717ccdbcb6a6932221cc6d16"
"sha256:aaff8552cd4c02f8ae30c82dd3ebe6c6c3a8e06cbc59991281534ba0bbe12890"
],
"index": "pypi",
"version": "==0.3.4"
"version": "==0.3.5"
},
"markupsafe": {
"hashes": [
@@ -266,6 +257,29 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.1"
},
"marshmallow": {
"hashes": [
"sha256:2272273505f1644580fbc66c6b220cc78f893eb31f1ecde2af98ad28011e9811",
"sha256:47911dd7c641a27160f0df5fd0fe94667160ffe97f70a42c3cc18388d86098cc"
],
"markers": "python_version >= '3.5'",
"version": "==3.8.0"
},
"outcome": {
"hashes": [
"sha256:ee46c5ce42780cde85d55a61819d0e6b8cb490f1dbd749ba75ff2629771dcd2d",
"sha256:fc7822068ba7dd0fc2532743611e8a73246708d3564e29a39f93d6ab3701b66f"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.0.1"
},
"priority": {
"hashes": [
"sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe",
"sha256:be4fcb94b5e37cdeb40af5533afe6dd603bd665fe9c8b3052610fc1001d5d1eb"
],
"version": "==1.3.0"
},
"pydantic": {
"hashes": [
"sha256:1783c1d927f9e1366e0e0609ae324039b2479a1a282a98ed6a6836c9ed02002c",
@@ -296,13 +310,62 @@
"index": "pypi",
"version": "==1.3.7"
},
"requests": {
"python-dotenv": {
"hashes": [
"sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
"sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
"sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d",
"sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"
],
"version": "==0.14.0"
},
"quart": {
"hashes": [
"sha256:9c634e4c1e4b21b824003c676de1583581258c72b0ac4d2ba747db846e97ff56",
"sha256:d885d782edd9d5dcfd2c4a56e020db3b82493d4c3950f91c221b7d88d239ac93"
],
"index": "pypi",
"version": "==2.24.0"
"version": "==0.13.1"
},
"quart-compress": {
"hashes": [
"sha256:41cd0cc8d26905a45025ddda7022461a71b9d1d950b21b006dc106a1c41c75ef",
"sha256:63af5e6370aa7850fb219d22e1db89965aeb13b8f27bc83e7f9a44118faa3c54"
],
"index": "pypi",
"version": "==0.2.1"
},
"quart-cors": {
"hashes": [
"sha256:020a17d504264db86cada3c1335ef174af28b33f57cee321ddc46d69c33d5c8e",
"sha256:c08bdb326219b6c186d19ed6a97a7fd02de8fe36c7856af889494c69b525c53c"
],
"index": "pypi",
"version": "==0.3.0"
},
"quart-trio": {
"hashes": [
"sha256:00f3b20f8d82ce7e81ead61db4efba38ed7653c7e28199defded46b663ab2595",
"sha256:dafc8f0440d4b70fa60d24122a161d2373894d2bfa9f713d9f1df1fd508f0834"
],
"index": "pypi",
"version": "==0.5.1"
},
"rfc3986": {
"extras": [
"idna2008"
],
"hashes": [
"sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d",
"sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"
],
"version": "==1.4.0"
},
"secure": {
"hashes": [
"sha256:4dc8dd4b548831c3ad7f94079332c41d67c781eccc32215ff5a8a49582c1a447",
"sha256:b3bf1e39ebf40040fc3248392343a5052aa14cb45fc87ec91b0bd11f19cc46bd"
],
"index": "pypi",
"version": "==0.2.1"
},
"shortuuid": {
"hashes": [
@@ -320,29 +383,44 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"sniffio": {
"hashes": [
"sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5",
"sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"
],
"markers": "python_version >= '3.5'",
"version": "==1.1.0"
},
"sortedcontainers": {
"hashes": [
"sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
"sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
],
"version": "==2.2.2"
},
"toml": {
"hashes": [
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
],
"version": "==0.10.1"
},
"trio": {
"hashes": [
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
],
"index": "pypi",
"version": "==0.17.0"
},
"typing-extensions": {
"hashes": [
"sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5",
"sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae",
"sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
],
"markers": "python_version < '3.8'",
"version": "==3.7.4.2"
},
"urllib3": {
"hashes": [
"sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==1.25.10"
},
"webassets": {
"hashes": [
"sha256:167132337677c8cedc9705090f6d48da3fb262c8e0b2773b29f3352f050181cd",
"sha256:a31a55147752ba1b3dc07dee0ad8c8efff274464e08bbdb88c1fd59ffd552724"
],
"version": "==2.0"
"index": "pypi",
"version": "==3.7.4.3"
},
"werkzeug": {
"hashes": [
@@ -351,6 +429,14 @@
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==1.0.1"
},
"wsproto": {
"hashes": [
"sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d",
"sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==0.15.0"
}
},
"develop": {
@@ -361,21 +447,28 @@
],
"version": "==1.4.4"
},
"async-generator": {
"hashes": [
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
],
"markers": "python_version >= '3.5'",
"version": "==1.10"
},
"attrs": {
"hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
"sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==19.3.0"
"version": "==20.2.0"
},
"black": {
"hashes": [
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
"sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"
],
"index": "pypi",
"version": "==19.10b0"
"version": "==20.8b1"
},
"click": {
"hashes": [
@@ -387,67 +480,50 @@
},
"coverage": {
"hashes": [
"sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb",
"sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3",
"sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716",
"sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034",
"sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3",
"sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8",
"sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0",
"sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f",
"sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4",
"sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962",
"sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d",
"sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b",
"sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4",
"sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3",
"sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258",
"sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59",
"sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01",
"sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd",
"sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b",
"sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d",
"sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89",
"sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd",
"sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b",
"sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d",
"sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46",
"sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546",
"sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082",
"sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b",
"sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4",
"sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8",
"sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811",
"sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd",
"sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651",
"sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0"
"sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
"sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
"sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
"sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
"sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
"sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
"sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
"sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
"sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
"sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
"sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
"sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
"sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
"sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
"sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
"sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
"sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
"sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
"sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
"sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
"sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
"sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
"sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
"sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
"sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
"sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
"sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
"sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
"sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
"sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
"sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
"sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
"sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
"sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==5.2.1"
"version": "==5.3"
},
"flake8": {
"idna": {
"hashes": [
"sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
"sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
"index": "pypi",
"version": "==3.8.3"
},
"flake8-mypy": {
"hashes": [
"sha256:47120db63aff631ee1f84bac6fe8e64731dc66da3efc1c51f85e15ade4a3ba18",
"sha256:cff009f4250e8391bf48990093cff85802778c345c8449d6498b62efefeebcbc"
],
"index": "pypi",
"version": "==17.8.0"
},
"importlib-metadata": {
"hashes": [
"sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83",
"sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"
],
"markers": "python_version < '3.8'",
"version": "==1.7.0"
"version": "==2.10"
},
"iniconfig": {
"hashes": [
@@ -456,40 +532,25 @@
],
"version": "==1.0.1"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"version": "==0.6.1"
},
"more-itertools": {
"hashes": [
"sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5",
"sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"
],
"markers": "python_version >= '3.5'",
"version": "==8.4.0"
},
"mypy": {
"hashes": [
"sha256:2c6cde8aa3426c1682d35190b59b71f661237d74b053822ea3d748e2c9578a7c",
"sha256:3fdda71c067d3ddfb21da4b80e2686b71e9e5c72cca65fa216d207a358827f86",
"sha256:5dd13ff1f2a97f94540fd37a49e5d255950ebcdf446fb597463a40d0df3fac8b",
"sha256:6731603dfe0ce4352c555c6284c6db0dc935b685e9ce2e4cf220abe1e14386fd",
"sha256:6bb93479caa6619d21d6e7160c552c1193f6952f0668cdda2f851156e85186fc",
"sha256:81c7908b94239c4010e16642c9102bfc958ab14e36048fa77d0be3289dda76ea",
"sha256:9c7a9a7ceb2871ba4bac1cf7217a7dd9ccd44c27c2950edbc6dc08530f32ad4e",
"sha256:a4a2cbcfc4cbf45cd126f531dedda8485671545b43107ded25ce952aac6fb308",
"sha256:b7fbfabdbcc78c4f6fc4712544b9b0d6bf171069c6e0e3cb82440dd10ced3406",
"sha256:c05b9e4fb1d8a41d41dec8786c94f3b95d3c5f528298d769eb8e73d293abc48d",
"sha256:d7df6eddb6054d21ca4d3c6249cae5578cb4602951fd2b6ee2f5510ffb098707",
"sha256:e0b61738ab504e656d1fe4ff0c0601387a5489ca122d55390ade31f9ca0e252d",
"sha256:eff7d4a85e9eea55afa34888dfeaccde99e7520b51f867ac28a48492c0b1130c",
"sha256:f05644db6779387ccdb468cc47a44b4356fc2ffa9287135d05b70a98dc83b89a"
"sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
"sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7",
"sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2",
"sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474",
"sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0",
"sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217",
"sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749",
"sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6",
"sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf",
"sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36",
"sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b",
"sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72",
"sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1",
"sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"
],
"markers": "python_version >= '3.5'",
"version": "==0.782"
"index": "pypi",
"version": "==0.761"
},
"mypy-extensions": {
"hashes": [
@@ -498,6 +559,14 @@
],
"version": "==0.4.3"
},
"outcome": {
"hashes": [
"sha256:ee46c5ce42780cde85d55a61819d0e6b8cb490f1dbd749ba75ff2629771dcd2d",
"sha256:fc7822068ba7dd0fc2532743611e8a73246708d3564e29a39f93d6ab3701b66f"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.0.1"
},
"packaging": {
"hashes": [
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
@@ -529,22 +598,6 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.9.0"
},
"pycodestyle": {
"hashes": [
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.6.0"
},
"pyflakes": {
"hashes": [
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.2.0"
},
"pyparsing": {
"hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
@@ -555,11 +608,11 @@
},
"pytest": {
"hashes": [
"sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4",
"sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad"
"sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9",
"sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92"
],
"index": "pypi",
"version": "==6.0.1"
"version": "==6.1.1"
},
"pytest-cov": {
"hashes": [
@@ -569,31 +622,45 @@
"index": "pypi",
"version": "==2.10.1"
},
"pytest-trio": {
"hashes": [
"sha256:3f48cc1df66d279d705af38ad38d1639c2e2380ddffcdc3a45bb81758de61f03",
"sha256:9bf0a490fd177a33617e8709242293fae47934de2b51f8209eb2c0545b6ca8fe"
],
"index": "pypi",
"version": "==0.6.0"
},
"regex": {
"hashes": [
"sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204",
"sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162",
"sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f",
"sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb",
"sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6",
"sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7",
"sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88",
"sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99",
"sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644",
"sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a",
"sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840",
"sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067",
"sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd",
"sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4",
"sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e",
"sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89",
"sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e",
"sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc",
"sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf",
"sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341",
"sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"
"sha256:088afc8c63e7bd187a3c70a94b9e50ab3f17e1d3f52a32750b5b77dbe99ef5ef",
"sha256:1fe0a41437bbd06063aa184c34804efa886bcc128222e9916310c92cd54c3b4c",
"sha256:3d20024a70b97b4f9546696cbf2fd30bae5f42229fbddf8661261b1eaff0deb7",
"sha256:41bb65f54bba392643557e617316d0d899ed5b4946dccee1cb6696152b29844b",
"sha256:4318d56bccfe7d43e5addb272406ade7a2274da4b70eb15922a071c58ab0108c",
"sha256:4707f3695b34335afdfb09be3802c87fa0bc27030471dbc082f815f23688bc63",
"sha256:49f23ebd5ac073765ecbcf046edc10d63dcab2f4ae2bce160982cb30df0c0302",
"sha256:5533a959a1748a5c042a6da71fe9267a908e21eded7a4f373efd23a2cbdb0ecc",
"sha256:5d892a4f1c999834eaa3c32bc9e8b976c5825116cde553928c4c8e7e48ebda67",
"sha256:5f18875ac23d9aa2f060838e8b79093e8bb2313dbaaa9f54c6d8e52a5df097be",
"sha256:60b0e9e6dc45683e569ec37c55ac20c582973841927a85f2d8a7d20ee80216ab",
"sha256:816064fc915796ea1f26966163f6845de5af78923dfcecf6551e095f00983650",
"sha256:84cada8effefe9a9f53f9b0d2ba9b7b6f5edf8d2155f9fdbe34616e06ececf81",
"sha256:84e9407db1b2eb368b7ecc283121b5e592c9aaedbe8c78b1a2f1102eb2e21d19",
"sha256:8d69cef61fa50c8133382e61fd97439de1ae623fe943578e477e76a9d9471637",
"sha256:9a02d0ae31d35e1ec12a4ea4d4cca990800f66a917d0fb997b20fbc13f5321fc",
"sha256:9bc13e0d20b97ffb07821aa3e113f9998e84994fe4d159ffa3d3a9d1b805043b",
"sha256:a6f32aea4260dfe0e55dc9733ea162ea38f0ea86aa7d0f77b15beac5bf7b369d",
"sha256:ae91972f8ac958039920ef6e8769277c084971a142ce2b660691793ae44aae6b",
"sha256:c570f6fa14b9c4c8a4924aaad354652366577b4f98213cf76305067144f7b100",
"sha256:c9443124c67b1515e4fe0bb0aa18df640965e1030f468a2a5dc2589b26d130ad",
"sha256:d23a18037313714fb3bb5a94434d3151ee4300bae631894b1ac08111abeaa4a3",
"sha256:eaf548d117b6737df379fdd53bdde4f08870e66d7ea653e230477f071f861121",
"sha256:ebbe29186a3d9b0c591e71b7393f1ae08c83cb2d8e517d2a822b8f7ec99dfd8b",
"sha256:eda4771e0ace7f67f58bc5b560e27fb20f32a148cbc993b0c3835970935c2707",
"sha256:f1b3afc574a3db3b25c89161059d857bd4909a1269b0b3cb3c904677c8c4a3f7",
"sha256:f2388013e68e750eaa16ccbea62d4130180c26abb1d8e5d584b9baf69672b30f"
],
"version": "==2020.7.14"
"version": "==2020.9.27"
},
"six": {
"hashes": [
@@ -603,6 +670,21 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"sniffio": {
"hashes": [
"sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5",
"sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"
],
"markers": "python_version >= '3.5'",
"version": "==1.1.0"
},
"sortedcontainers": {
"hashes": [
"sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
"sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
],
"version": "==2.2.2"
},
"toml": {
"hashes": [
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
@@ -610,6 +692,14 @@
],
"version": "==0.10.1"
},
"trio": {
"hashes": [
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
],
"index": "pypi",
"version": "==0.17.0"
},
"typed-ast": {
"hashes": [
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
@@ -638,20 +728,12 @@
},
"typing-extensions": {
"hashes": [
"sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5",
"sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae",
"sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
],
"markers": "python_version < '3.8'",
"version": "==3.7.4.2"
},
"zipp": {
"hashes": [
"sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b",
"sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"
],
"markers": "python_version >= '3.6'",
"version": "==3.1.0"
"index": "pypi",
"version": "==3.7.4.3"
}
}
}
+1 -1
View File
@@ -1 +1 @@
web: gunicorn -b :5000 lnbits:app -k gevent
web: hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
+21 -9
View File
@@ -1,9 +1,12 @@
LNbits
======
[![github-actions-badge]][github-actions]
[![github-tests-badge]][github-tests]
[![github-mypy-badge]][github-mypy]
[![codecov-badge]][codecov]
[![license-badge]](LICENSE)
[![docs-badge]][docs]
![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png)
@@ -19,13 +22,21 @@ LNbits is a very simple Python server that sits on top of any funding source, an
* Fallback wallet for the LNURL scheme
* Instant wallet for LN demonstrations
The wallet can run on top of any lightning-network funding source, currently there is support for LND, CLightning, Lntxbot, LNpay, OpenNode, with more being added regularily.
The wallet can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularily.
See [lnbits.org](https://lnbits.org) for more detailed documentation.
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as excellent funding sources for LNbits!
LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as excellent funding sources for LNbits.
## Running LNbits
See the [install guide](docs/guide/installation.md) for details on installation and setup.
### Contributing to LNbits
There's a [slightly different setup](docs/devs/installation.md) if you want to contribute to LNbits, but if your changes don't require adding or removing any package dependencies you don't have to bother with that, just follow the [normal installation](docs/guide/installation.md) steps.
## LNbits as an account system
@@ -57,16 +68,17 @@ Wallets can be easily generated and given out to people at events (one click mul
![lnurl ATM](https://i.imgur.com/xFWDnwy.png)
## Running LNbits locally
See the [development docs](docs/devs/installation.md) for details on installation and setup.
## Tip me
If you like this project and might even use or extend it, why not [send some tip love](https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
[github-actions]: https://github.com/lnbits/lnbits/actions
[github-actions-badge]: https://github.com/lnbits/lnbits/workflows/test%20suite/badge.svg
[docs]: https://lnbits.org/
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
[github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
[github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
[github-tests]: https://github.com/lnbits/lnbits/actions?query=workflow%3Atests
[github-tests-badge]: https://github.com/lnbits/lnbits/workflows/tests/badge.svg
[codecov]: https://codecov.io/gh/lnbits/lnbits
[codecov-badge]: https://codecov.io/gh/lnbits/lnbits/branch/master/graph/badge.svg
[license-badge]: https://img.shields.io/badge/license-MIT-blue.svg
+1 -1
View File
@@ -1,7 +1,7 @@
{
"scripts": {
"dokku": {
"predeploy": "flask migrate"
"predeploy": "quart migrate && quart assets"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
---
layout: default
title: For developers
nav_order: 3
nav_order: 4
has_children: true
---
+20 -1
View File
@@ -19,6 +19,25 @@ find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change al
Going over the example extension's structure:
* views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
* views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
* migrations.py: Create database tables for your plugin. They'll be created when you run `pipenv run flask migrate`.
* migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
... This document is a work-in-progress. Send pull requests if you get stuck, so others don't.
Adding new dependencies
-----------------------
If for some reason your extensions needs a new python package to work, you can add a new package using Pipenv:
```sh
$ pipenv install new_package_name
```
This will create a new entry in the `Pipenv` file.
**But we need an extra step to make sure LNbits doesn't break in production.**
All tests and deployments should run against the `requirements.txt` file so every time a new package is added
it is necessary to run the Pipenv `lock` command and manually update the requirements file:
```sh
$ pipenv lock -r
```
+20 -14
View File
@@ -5,10 +5,12 @@ title: Installation
nav_order: 1
---
Installation
============
Download this repo and install the dependencies.
Download the latest stable release https://github.com/lnbits/lnbits/releases
Application dependencies
------------------------
@@ -20,35 +22,39 @@ While in development, you will need to install all dependencies:
$ pipenv shell
$ pipenv install --dev
```
If you wish to use a version of Python higher than 3.7:
```sh
$ pipenv --python 3.8 install --dev
```
You will need to set the variables in `.env.example`, and rename the file to `.env`.
You will need to copy `.env.example` to `.env`, then set variables there.
![Files](https://i.imgur.com/ri2zOe8.png)
You might also need to install additional packages, depending on the [backend wallets](../guide/wallets.md) you configured.
E.g. when you want to use LND you have to `pipenv install lnd-grpc`.
You might also need to install additional packages, depending on the [backend wallet](../guide/wallets.md) you use.
E.g. when you want to use LND you have to `pipenv run pip install lndgrpc` and `pipenv run pip install purerpc`.
Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment.
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
Running the server
------------------
LNbits uses [Flask](http://flask.pocoo.org/) as an application server.
LNbits uses [Quart][quart] as an application server.
```sh
$ pipenv run flask migrate
$ pipenv run flask run
$ pipenv run python -m lnbits
```
There is an environment variable called `FLASK_ENV` that has to be set to `development`
if you want to run Flask in debug mode with autoreload
[pipenv]: https://pipenv.pypa.io/
Frontend
--------
The views are build using [Vue.js and Quasar](https://quasar.dev/start/how-to-use-vue).
The frontend uses [Vue.js and Quasar][quasar].
[quart]: https://pgjones.gitlab.io/
[pipenv]: https://pipenv.pypa.io/
[polar]: https://lightningpolar.com/
[quasar]: https://quasar.dev/start/how-to-use-vue
+30
View File
@@ -0,0 +1,30 @@
---
layout: default
title: Basic installation
nav_order: 2
---
Basic installation
==================
Download this repo and install the dependencies:
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits/
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
cp .env.example .env
./venv/bin/quart assets
./venv/bin/quart migrate
./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
```
No you can visit your LNbits at http://localhost:5000/.
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
Then you can run restart it and it will be using the new settings.
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
+24 -25
View File
@@ -1,7 +1,7 @@
---
layout: default
title: Backend wallets
nav_order: 2
nav_order: 3
---
@@ -21,59 +21,58 @@ Using this wallet requires the installation of the `pylightning` Python package.
- `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet**
- `CLIGHTNING_RPC`: /file/path/lightning-rpc
### Spark (c-lightning)
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
- `SPARK_URL`: http://10.147.17.230:9737/rpc
- `SPARK_TOKEN`: secret_access_key
### LND (gRPC)
Using this wallet requires the installation of the `lnd-grpc` Python package.
Using this wallet requires the installation of the `lndgrpc` and `purerpc` Python packages.
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
- `LND_GRPC_ENDPOINT`: ip_address
- `LND_GRPC_PORT`: port
- `LND_CERT`: /file/path/tls.cert
- `LND_ADMIN_MACAROON`: /file/path/admin.macaroon
- `LND_INVOICE_MACAROON`: /file/path/invoice.macaroon
- `LND_READ_MACAROON`: /file/path/read.macaroon
- `LND_GRPC_CERT`: /file/path/tls.cert
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
### LND (REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: ip_address
- `LND_CERT`: /file/path/tls.cert
- `LND_ADMIN_MACAROON`: /file/path/admin.macaroon
- `LND_INVOICE_MACAROON`: /file/path/invoice.macaroon
- `LND_READ_MACAROON`: /file/path/read.macaroon
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
### LNbits
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
- `LNBITS_ENDPOINT`: ex. https://lnbits.com
- `LNBITS_ADMIN_KEY`: apiKey
- `LNBITS_INVOICE_KEY`: apiKey
- `LNBITS_ENDPOINT`: e.g. https://lnbits.com
- `LNBITS_KEY`: lnbitsAdminKey
### LNPay
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret.
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
- `LNPAY_API_ENDPOINT`: ex. https://lnpay.co/v1/
- `LNPAY_API_KEY`: apiKey
- `LNPAY_ADMIN_KEY`: apiKey
- `LNPAY_INVOICE_KEY`: apiKey
- `LNPAY_READ_KEY`: apiKey
- `LNPAY_API_ENDPOINT`: https://lnpay.co/v1/
- `LNPAY_API_KEY`: sak_apiKey
- `LNPAY_WALLET_KEY`: waka_apiKey
### lntxbot
- `LNBITS_BACKEND_WALLET_CLASS`: **LntxbotWallet**
- `LNTXBOT_API_ENDPOINT`: ex. https://lntxbot.bigsun.xyz/
- `LNTXBOT_ADMIN_KEY`: apiKey
- `LNTXBOT_INVOICE_KEY`: apiKey
- `LNTXBOT_API_ENDPOINT`: https://lntxbot.bigsun.xyz/
- `LNTXBOT_KEY`: lntxbotAdminApiKey
### OpenNode
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
- `OPENNODE_API_ENDPOINT`: ex. https://api.opennode.com/
- `OPENNODE_ADMIN_KEY`: apiKey
- `OPENNODE_INVOICE_KEY`: apiKey
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey
-92
View File
@@ -1,92 +0,0 @@
import importlib
from flask import Flask
from flask_assets import Environment, Bundle # type: ignore
from flask_compress import Compress # type: ignore
from flask_cors import CORS # type: ignore
from flask_talisman import Talisman # type: ignore
from os import getenv
from werkzeug.middleware.proxy_fix import ProxyFix
from .core import core_app, migrations as core_migrations
from .helpers import ExtensionManager
from .settings import FORCE_HTTPS
disabled_extensions = getenv("LNBITS_DISABLED_EXTENSIONS", "").split(",")
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) # type: ignore
valid_extensions = [ext for ext in ExtensionManager(disabled=disabled_extensions).extensions if ext.is_valid]
# optimization & security
# -----------------------
Compress(app)
CORS(app)
Talisman(
app,
force_https=FORCE_HTTPS,
content_security_policy={
"default-src": [
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"blob:",
"api.opennode.co",
]
},
)
# blueprints / extensions
# -----------------------
app.register_blueprint(core_app)
for ext in valid_extensions:
try:
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
app.register_blueprint(getattr(ext_module, f"{ext.code}_ext"), url_prefix=f"/{ext.code}")
except Exception:
raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.")
# filters
# -------
app.jinja_env.globals["DEBUG"] = app.config["DEBUG"]
app.jinja_env.globals["EXTENSIONS"] = valid_extensions
app.jinja_env.globals["SITE_TITLE"] = getenv("LNBITS_SITE_TITLE", "LNbits")
# assets
# ------
assets = Environment(app)
assets.url = app.static_url_path
assets.register("base_css", Bundle("scss/base.scss", filters="pyscss", output="css/base.css"))
# commands
# --------
@app.cli.command("migrate")
def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
core_migrations.migrate()
for ext in valid_extensions:
try:
ext_migrations = importlib.import_module(f"lnbits.extensions.{ext.code}.migrations")
ext_migrations.migrate()
except Exception:
raise ImportError(f"Please make sure that the extension `{ext.code}` has a migrations file.")
# init
# ----
if __name__ == "__main__":
app.run()
+22
View File
@@ -0,0 +1,22 @@
from .app import create_app
from .commands import migrate_databases, transpile_scss, bundle_vendored
from .settings import LNBITS_SITE_TITLE, SERVICE_FEE, DEBUG, LNBITS_DATA_FOLDER, WALLET, LNBITS_COMMIT
migrate_databases()
transpile_scss()
bundle_vendored()
app = create_app()
print(
f"""Starting LNbits with
- git version: {LNBITS_COMMIT}
- site title: {LNBITS_SITE_TITLE}
- debug: {DEBUG}
- data folder: {LNBITS_DATA_FOLDER}
- funding source: {WALLET.__class__.__name__}
- service fee: {SERVICE_FEE}
"""
)
app.run(host=app.config["HOST"], port=app.config["PORT"])
-7
View File
@@ -1,7 +0,0 @@
{
"scripts": {
"dokku": {
"predeploy": "flask migrate"
}
}
}
+135
View File
@@ -0,0 +1,135 @@
import importlib
import warnings
from quart import g
from quart_trio import QuartTrio
from quart_cors import cors # type: ignore
from quart_compress import Compress # type: ignore
from secure import SecureHeaders # type: ignore
from .commands import db_migrate, handle_assets
from .core import core_app
from .db import open_db, open_ext_db
from .helpers import get_valid_extensions, get_js_vendored, get_css_vendored, url_for_vendored
from .proxy_fix import ASGIProxyFix
from .tasks import run_deferred_async, invoice_listener, internal_invoice_listener, webhook_handler, grab_app_for_later
from .settings import WALLET
secure_headers = SecureHeaders(hsts=False)
def create_app(config_object="lnbits.settings") -> QuartTrio:
"""Create application factory.
:param config_object: The configuration object to use.
"""
app = QuartTrio(__name__, static_folder="static")
app.config.from_object(config_object)
app.asgi_http_class = ASGIProxyFix
cors(app)
Compress(app)
check_funding_source(app)
register_assets(app)
register_blueprints(app)
register_filters(app)
register_commands(app)
register_request_hooks(app)
register_async_tasks(app)
grab_app_for_later(app)
return app
def check_funding_source(app: QuartTrio) -> None:
@app.before_serving
async def check_wallet_status():
error_message, balance = WALLET.status()
if error_message:
warnings.warn(
f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning,
)
else:
print(f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat.")
def register_blueprints(app: QuartTrio) -> None:
"""Register Flask blueprints / LNbits extensions."""
app.register_blueprint(core_app)
for ext in get_valid_extensions():
try:
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
bp = getattr(ext_module, f"{ext.code}_ext")
@bp.before_request
async def before_request():
g.ext_db = open_ext_db(ext.code)
@bp.teardown_request
async def after_request(exc):
g.ext_db.close()
app.register_blueprint(bp, url_prefix=f"/{ext.code}")
except Exception:
raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.")
def register_commands(app: QuartTrio):
"""Register Click commands."""
app.cli.add_command(db_migrate)
app.cli.add_command(handle_assets)
def register_assets(app: QuartTrio):
"""Serve each vendored asset separately or a bundle."""
@app.before_request
async def vendored_assets_variable():
if app.config["DEBUG"]:
g.VENDORED_JS = map(url_for_vendored, get_js_vendored())
g.VENDORED_CSS = map(url_for_vendored, get_css_vendored())
else:
g.VENDORED_JS = ["/static/bundle.js"]
g.VENDORED_CSS = ["/static/bundle.css"]
def register_filters(app: QuartTrio):
"""Jinja filters."""
app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"]
app.jinja_env.globals["EXTENSIONS"] = get_valid_extensions()
def register_request_hooks(app: QuartTrio):
"""Open the core db for each request so everything happens in a big transaction"""
@app.before_request
async def before_request():
g.db = open_db()
g.nursery = app.nursery
@app.after_request
async def set_secure_headers(response):
secure_headers.quart(response)
return response
@app.teardown_request
async def after_request(exc):
g.db.close()
def register_async_tasks(app):
@app.route("/wallet/webhook", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
async def webhook_listener():
return await webhook_handler()
@app.before_serving
async def listeners():
run_deferred_async(app.nursery)
app.nursery.start_soon(invoice_listener)
app.nursery.start_soon(internal_invoice_listener)
@app.after_serving
async def stop_listeners():
pass
+95 -32
View File
@@ -1,66 +1,121 @@
# type: ignore
import bitstring
import bitstring # type: ignore
import re
from binascii import hexlify
from bech32 import bech32_decode, CHARSET
import hashlib
from typing import List, NamedTuple, Optional
from bech32 import bech32_decode, CHARSET # type: ignore
from ecdsa import SECP256k1, VerifyingKey # type: ignore
from ecdsa.util import sigdecode_string # type: ignore
from binascii import unhexlify
class Route(NamedTuple):
pubkey: str
short_channel_id: str
base_fee_msat: int
ppm_fee: int
cltv: int
class Invoice(object):
def __init__(self):
self.payment_hash: str = None
self.amount_msat: int = 0
self.description: str = None
payment_hash: str
amount_msat: int = 0
description: Optional[str] = None
description_hash: Optional[str] = None
payee: Optional[str] = None
date: int
expiry: int = 3600
secret: Optional[str] = None
route_hints: List[Route] = []
min_final_cltv_expiry: int = 18
def decode(pr: str) -> Invoice:
""" Super naïve bolt11 decoder,
only gets payment_hash, description/description_hash and amount in msatoshi.
"""bolt11 decoder,
based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py
"""
hrp, data = bech32_decode(pr)
if not hrp:
raise ValueError("Bad bech32 checksum")
hrp, decoded_data = bech32_decode(pr)
if hrp is None or decoded_data is None:
raise ValueError("Bad bech32 checksum")
if not hrp.startswith("ln"):
raise ValueError("Does not start with ln")
data = u5_to_bitarray(data)
bitarray = _u5_to_bitarray(decoded_data)
# Final signature 65 bytes, split it off.
if len(data) < 65 * 8:
# final signature 65 bytes, split it off.
if len(bitarray) < 65 * 8:
raise ValueError("Too short to contain signature")
data = bitstring.ConstBitStream(data[: -65 * 8])
# extract the signature
signature = bitarray[-65 * 8 :].tobytes()
# the tagged fields as a bitstream
data = bitstring.ConstBitStream(bitarray[: -65 * 8])
# build the invoice object
invoice = Invoice()
# decode the amount from the hrp
m = re.search("[^\d]+", hrp[2:])
if m:
amountstr = hrp[2 + m.end() :]
if amountstr != "":
invoice.amount_msat = unshorten_amount(amountstr)
invoice.amount_msat = _unshorten_amount(amountstr)
# pull out date
data.read(35).uint
invoice.date = data.read(35).uint
while data.pos != data.len:
tag, tagdata, data = pull_tagged(data)
tag, tagdata, data = _pull_tagged(data)
data_length = len(tagdata) / 5
if tag == "d":
invoice.description = trim_to_bytes(tagdata).decode("utf-8")
invoice.description = _trim_to_bytes(tagdata).decode("utf-8")
elif tag == "h" and data_length == 52:
invoice.description = hexlify(trim_to_bytes(tagdata)).decode("ascii")
invoice.description_hash = _trim_to_bytes(tagdata).hex()
elif tag == "p" and data_length == 52:
invoice.payment_hash = hexlify(trim_to_bytes(tagdata)).decode("ascii")
invoice.payment_hash = _trim_to_bytes(tagdata).hex()
elif tag == "x":
invoice.expiry = tagdata.uint
elif tag == "n":
invoice.payee = _trim_to_bytes(tagdata).hex()
# this won't work in most cases, we must extract the payee
# from the signature
elif tag == "s":
invoice.secret = _trim_to_bytes(tagdata).hex()
elif tag == "r":
s = bitstring.ConstBitStream(tagdata)
while s.pos + 264 + 64 + 32 + 32 + 16 < s.len:
route = Route(
pubkey=s.read(264).tobytes().hex(),
short_channel_id=_readable_scid(s.read(64).intbe),
base_fee_msat=s.read(32).intbe,
ppm_fee=s.read(32).intbe,
cltv=s.read(16).intbe,
)
invoice.route_hints.append(route)
# BOLT #11:
# A reader MUST check that the `signature` is valid (see the `n` tagged
# field specified below).
# A reader MUST use the `n` field to validate the signature instead of
# performing signature recovery if a valid `n` field is provided.
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
sig = signature[0:64]
if invoice.payee:
key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1)
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
else:
keys = VerifyingKey.from_public_key_recovery(sig, message, SECP256k1, hashlib.sha256)
signaling_byte = signature[64]
key = keys[int(signaling_byte)]
invoice.payee = key.to_string("compressed").hex()
return invoice
def unshorten_amount(amount: str) -> int:
""" Given a shortened amount, return millisatoshis
"""
def _unshorten_amount(amount: str) -> int:
"""Given a shortened amount, return millisatoshis"""
# BOLT #11:
# The following `multiplier` letters are defined:
#
@@ -83,18 +138,18 @@ def unshorten_amount(amount: str) -> int:
raise ValueError("Invalid amount '{}'".format(amount))
if unit in units:
return int(amount[:-1]) * 100_000_000_000 / units[unit]
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
else:
return int(amount) * 100_000_000_000
def pull_tagged(stream):
def _pull_tagged(stream):
tag = stream.read(5).uint
length = stream.read(5).uint * 32 + stream.read(5).uint
return (CHARSET[tag], stream.read(length * 5), stream)
def trim_to_bytes(barr):
def _trim_to_bytes(barr):
# Adds a byte if necessary.
b = barr.tobytes()
if barr.len % 8 != 0:
@@ -102,7 +157,15 @@ def trim_to_bytes(barr):
return b
def u5_to_bitarray(arr):
def _readable_scid(short_channel_id: int) -> str:
return "{blockheight}x{transactionindex}x{outputindex}".format(
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
outputindex=(short_channel_id & 0xFFFF),
)
def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
ret = bitstring.BitArray()
for a in arr:
ret += bitstring.pack("uint:5", a)
+83
View File
@@ -0,0 +1,83 @@
import warnings
import click
import importlib
import re
import os
import sqlite3
from .core import migrations as core_migrations
from .db import open_db, open_ext_db
from .helpers import get_valid_extensions, get_css_vendored, get_js_vendored, url_for_vendored
from .settings import LNBITS_PATH
@click.command("migrate")
def db_migrate():
migrate_databases()
@click.command("assets")
def handle_assets():
transpile_scss()
bundle_vendored()
def transpile_scss():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from scss.compiler import compile_string # type: ignore
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
css.write(compile_string(scss.read()))
def bundle_vendored():
for getfiles, outputpath in [
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
(get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")),
]:
output = ""
for path in getfiles():
with open(path) as f:
output += "/* " + url_for_vendored(path) + " */\n" + f.read() + ";\n"
with open(outputpath, "w") as f:
f.write(output)
def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
with open_db() as core_db:
try:
rows = core_db.fetchall("SELECT * FROM dbversions")
except sqlite3.OperationalError:
# migration 3 wasn't ran
core_migrations.m000_create_migrations_table(core_db)
rows = core_db.fetchall("SELECT * FROM dbversions")
current_versions = {row["db"]: row["version"] for row in rows}
matcher = re.compile(r"^m(\d\d\d)_")
def run_migration(db, migrations_module):
db_name = migrations_module.__name__.split(".")[-2]
for key, run_migration in migrations_module.__dict__.items():
match = match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_versions.get(db_name, 0):
print(f"running migration {db_name}.{version}")
run_migration(db)
core_db.execute(
"INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)", (db_name, version)
)
run_migration(core_db, core_migrations)
for ext in get_valid_extensions():
try:
ext_migrations = importlib.import_module(f"lnbits.extensions.{ext.code}.migrations")
with open_ext_db(ext.code) as db:
run_migration(db, ext_migrations)
except ImportError:
raise ImportError(f"Please make sure that the extension `{ext.code}` has a migrations file.")
+9 -3
View File
@@ -1,9 +1,15 @@
from flask import Blueprint
from quart import Blueprint
core_app: Blueprint = Blueprint("core", __name__, template_folder="templates", static_folder="static")
core_app: Blueprint = Blueprint(
"core", __name__, template_folder="templates", static_folder="static", static_url_path="/core/static"
)
from .views.api import * # noqa
from .views.generic import * # noqa
from .views.lnurl import * # noqa
from .tasks import register_listeners
from lnbits.tasks import record_async
core_app.record(record_async(register_listeners))
+221 -125
View File
@@ -1,7 +1,10 @@
from typing import List, Optional
import json
import datetime
from uuid import uuid4
from typing import List, Optional, Dict
from quart import g
from lnbits.db import open_db
from lnbits import bolt11
from lnbits.settings import DEFAULT_WALLET_NAME
from .models import User, Wallet, Payment
@@ -12,9 +15,8 @@ from .models import User, Wallet, Payment
def create_account() -> User:
with open_db() as db:
user_id = uuid4().hex
db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
user_id = uuid4().hex
g.db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
new_account = get_account(user_id=user_id)
assert new_account, "Newly created account couldn't be retrieved"
@@ -23,26 +25,24 @@ def create_account() -> User:
def get_account(user_id: str) -> Optional[User]:
with open_db() as db:
row = db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,))
row = g.db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,))
return User(**row) if row else None
def get_user(user_id: str) -> Optional[User]:
with open_db() as db:
user = db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,))
user = g.db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,))
if user:
extensions = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,))
wallets = db.fetchall(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE user = ?
""",
(user_id,),
)
if user:
extensions = g.db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,))
wallets = g.db.fetchall(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE user = ?
""",
(user_id,),
)
return (
User(**{**user, **{"extensions": [e[0] for e in extensions], "wallets": [Wallet(**w) for w in wallets]}})
@@ -52,14 +52,13 @@ def get_user(user_id: str) -> Optional[User]:
def update_user_extension(*, user_id: str, extension: str, active: int) -> None:
with open_db() as db:
db.execute(
"""
INSERT OR REPLACE INTO extensions (user, extension, active)
VALUES (?, ?, ?)
""",
(user_id, extension, active),
)
g.db.execute(
"""
INSERT OR REPLACE INTO extensions (user, extension, active)
VALUES (?, ?, ?)
""",
(user_id, extension, active),
)
# wallets
@@ -67,15 +66,14 @@ def update_user_extension(*, user_id: str, extension: str, active: int) -> None:
def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet:
with open_db() as db:
wallet_id = uuid4().hex
db.execute(
"""
INSERT INTO wallets (id, name, user, adminkey, inkey)
VALUES (?, ?, ?, ?, ?)
""",
(wallet_id, wallet_name or DEFAULT_WALLET_NAME, user_id, uuid4().hex, uuid4().hex),
)
wallet_id = uuid4().hex
g.db.execute(
"""
INSERT INTO wallets (id, name, user, adminkey, inkey)
VALUES (?, ?, ?, ?, ?)
""",
(wallet_id, wallet_name or DEFAULT_WALLET_NAME, user_id, uuid4().hex, uuid4().hex),
)
new_wallet = get_wallet(wallet_id=wallet_id)
assert new_wallet, "Newly created wallet couldn't be retrieved"
@@ -84,100 +82,158 @@ def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet:
def delete_wallet(*, user_id: str, wallet_id: str) -> None:
with open_db() as db:
db.execute(
"""
UPDATE wallets AS w
SET
user = 'del:' || w.user,
adminkey = 'del:' || w.adminkey,
inkey = 'del:' || w.inkey
WHERE id = ? AND user = ?
""",
(wallet_id, user_id),
)
g.db.execute(
"""
UPDATE wallets AS w
SET
user = 'del:' || w.user,
adminkey = 'del:' || w.adminkey,
inkey = 'del:' || w.inkey
WHERE id = ? AND user = ?
""",
(wallet_id, user_id),
)
def get_wallet(wallet_id: str) -> Optional[Wallet]:
with open_db() as db:
row = db.fetchone(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE id = ?
""",
(wallet_id,),
)
row = g.db.fetchone(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE id = ?
""",
(wallet_id,),
)
return Wallet(**row) if row else None
def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
with open_db() as db:
row = db.fetchone(
f"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE adminkey = ? OR inkey = ?
""",
(key, key),
)
row = g.db.fetchone(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE adminkey = ? OR inkey = ?
""",
(key, key),
)
if not row:
return None
if not row:
return None
if key_type == "admin" and row["adminkey"] != key:
return None
if key_type == "admin" and row["adminkey"] != key:
return None
return Wallet(**row)
return Wallet(**row)
# wallet payments
# ---------------
def get_wallet_payment(wallet_id: str, checking_id: str) -> Optional[Payment]:
with open_db() as db:
row = db.fetchone(
def get_standalone_payment(checking_id: str) -> Optional[Payment]:
row = g.db.fetchone(
"""
SELECT *
FROM apipayments
WHERE checking_id = ?
""",
(checking_id,),
)
return Payment.from_row(row) if row else None
def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]:
row = g.db.fetchone(
"""
SELECT *
FROM apipayments
WHERE wallet = ? AND hash = ?
""",
(wallet_id, payment_hash),
)
return Payment.from_row(row) if row else None
def get_wallet_payments(
wallet_id: str,
*,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
exclude_uncheckable: bool = False,
) -> List[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
clause = ""
if complete and pending:
clause += ""
elif complete:
clause += "AND ((amount > 0 AND pending = 0) OR amount < 0)"
elif pending:
clause += "AND pending = 1"
else:
raise TypeError("at least one of [complete, pending] must be True.")
clause += " "
if outgoing and incoming:
clause += ""
elif outgoing:
clause += "AND amount < 0"
elif incoming:
clause += "AND amount > 0"
else:
raise TypeError("at least one of [outgoing, incoming] must be True.")
clause += " "
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
clause += "AND checking_id NOT LIKE 'temp_%' "
clause += "AND checking_id NOT LIKE 'internal_%' "
rows = g.db.fetchall(
f"""
SELECT *
FROM apipayments
WHERE wallet = ? {clause}
ORDER BY time DESC
""",
(wallet_id,),
)
return [Payment.from_row(row) for row in rows]
def delete_expired_invoices() -> None:
rows = g.db.fetchall(
"""
SELECT bolt11
FROM apipayments
WHERE pending = 1 AND amount > 0 AND time < strftime('%s', 'now') - 86400
"""
)
for (payment_request,) in rows:
try:
invoice = bolt11.decode(payment_request)
except:
continue
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration_date > datetime.datetime.utcnow():
continue
g.db.execute(
"""
SELECT payhash as checking_id, amount, fee, pending, memo, time
FROM apipayments
WHERE wallet = ? AND payhash = ?
DELETE FROM apipayments
WHERE pending = 1 AND hash = ?
""",
(wallet_id, checking_id),
)
return Payment(**row) if row else None
def get_wallet_payments(wallet_id: str, *, include_all_pending: bool = False) -> List[Payment]:
with open_db() as db:
if include_all_pending:
clause = "pending = 1"
else:
clause = "((amount > 0 AND pending = 0) OR amount < 0)"
rows = db.fetchall(
f"""
SELECT payhash as checking_id, amount, fee, pending, memo, time
FROM apipayments
WHERE wallet = ? AND {clause}
ORDER BY time DESC
""",
(wallet_id,),
)
return [Payment(**row) for row in rows]
def delete_wallet_payments_expired(wallet_id: str, *, seconds: int = 86400) -> None:
with open_db() as db:
db.execute(
"""
DELETE
FROM apipayments WHERE wallet = ? AND pending = 1 AND time < strftime('%s', 'now') - ?
""",
(wallet_id, seconds),
(invoice.payment_hash,),
)
@@ -186,28 +242,68 @@ def delete_wallet_payments_expired(wallet_id: str, *, seconds: int = 86400) -> N
def create_payment(
*, wallet_id: str, checking_id: str, amount: int, memo: str, fee: int = 0, pending: bool = True
*,
wallet_id: str,
checking_id: str,
payment_request: str,
payment_hash: str,
amount: int,
memo: str,
fee: int = 0,
preimage: Optional[str] = None,
pending: bool = True,
extra: Optional[Dict] = None,
) -> Payment:
with open_db() as db:
db.execute(
"""
INSERT INTO apipayments (wallet, payhash, amount, pending, memo, fee)
VALUES (?, ?, ?, ?, ?, ?)
""",
(wallet_id, checking_id, amount, int(pending), memo, fee),
)
g.db.execute(
"""
INSERT INTO apipayments
(wallet, checking_id, bolt11, hash, preimage,
amount, pending, memo, fee, extra)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
wallet_id,
checking_id,
payment_request,
payment_hash,
preimage,
amount,
int(pending),
memo,
fee,
json.dumps(extra) if extra and extra != {} and type(extra) is dict else None,
),
)
new_payment = get_wallet_payment(wallet_id, checking_id)
new_payment = get_wallet_payment(wallet_id, payment_hash)
assert new_payment, "Newly created payment couldn't be retrieved"
return new_payment
def update_payment_status(checking_id: str, pending: bool) -> None:
with open_db() as db:
db.execute("UPDATE apipayments SET pending = ? WHERE payhash = ?", (int(pending), checking_id,))
g.db.execute(
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
(
int(pending),
checking_id,
),
)
def delete_payment(checking_id: str) -> None:
with open_db() as db:
db.execute("DELETE FROM apipayments WHERE payhash = ?", (checking_id,))
g.db.execute("DELETE FROM apipayments WHERE checking_id = ?", (checking_id,))
def check_internal(payment_hash: str) -> Optional[str]:
row = g.db.fetchone(
"""
SELECT checking_id FROM apipayments
WHERE hash = ? AND pending AND amount > 0
""",
(payment_hash,),
)
if not row:
return None
else:
return row["checking_id"]
+51 -4
View File
@@ -1,4 +1,15 @@
from lnbits.db import open_db
import sqlite3
def m000_create_migrations_table(db):
db.execute(
"""
CREATE TABLE dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
"""
)
def m001_initial(db):
@@ -51,6 +62,7 @@ def m001_initial(db):
);
"""
)
db.execute(
"""
CREATE VIEW IF NOT EXISTS balances AS
@@ -70,6 +82,41 @@ def m001_initial(db):
)
def migrate():
with open_db() as db:
m001_initial(db)
def m002_add_fields_to_apipayments(db):
"""
Adding fields to apipayments for better accounting,
and renaming payhash to checking_id since that is what it really is.
"""
try:
db.execute("ALTER TABLE apipayments RENAME COLUMN payhash TO checking_id")
db.execute("ALTER TABLE apipayments ADD COLUMN hash TEXT")
db.execute("CREATE INDEX by_hash ON apipayments (hash)")
db.execute("ALTER TABLE apipayments ADD COLUMN preimage TEXT")
db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
import json
rows = db.fetchall("SELECT * FROM apipayments")
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
for ext in ["withdraw", "events", "lnticket", "paywall", "tpos"]:
prefix = "#" + ext + " "
if row["memo"].startswith(prefix):
new = row["memo"][len(prefix) :]
db.execute(
"""
UPDATE apipayments SET extra = ?, memo = ?
WHERE checking_id = ? AND memo = ?
""",
(json.dumps({"tag": ext}), new, row["checking_id"], row["memo"]),
)
break
except sqlite3.OperationalError:
# this is necessary now because it may be the case that this migration will
# run twice in some environments.
# catching errors like this won't be necessary in anymore now that we
# keep track of db versions so no migration ever runs twice.
pass
+78 -11
View File
@@ -1,4 +1,11 @@
from typing import List, NamedTuple, Optional
import json
import hmac
import hashlib
from ecdsa import SECP256k1, SigningKey # type: ignore
from typing import List, NamedTuple, Optional, Dict
from sqlite3 import Row
from lnbits.settings import WALLET
class User(NamedTuple):
@@ -27,22 +34,42 @@ class Wallet(NamedTuple):
@property
def balance(self) -> int:
return self.balance // 1000
return self.balance_msat // 1000
def get_payment(self, checking_id: str) -> Optional["Payment"]:
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest()
linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256")
return SigningKey.from_string(
linking_key,
curve=SECP256k1,
hashfunc=hashlib.sha256,
)
def get_payment(self, payment_hash: str) -> Optional["Payment"]:
from .crud import get_wallet_payment
return get_wallet_payment(self.id, checking_id)
return get_wallet_payment(self.id, payment_hash)
def get_payments(self, *, include_all_pending: bool = False) -> List["Payment"]:
def get_payments(
self,
*,
complete: bool = True,
pending: bool = False,
outgoing: bool = True,
incoming: bool = True,
exclude_uncheckable: bool = False
) -> List["Payment"]:
from .crud import get_wallet_payments
return get_wallet_payments(self.id, include_all_pending=include_all_pending)
def delete_expired_payments(self, seconds: int = 86400) -> None:
from .crud import delete_wallet_payments_expired
delete_wallet_payments_expired(self.id, seconds=seconds)
return get_wallet_payments(
self.id,
complete=complete,
pending=pending,
outgoing=outgoing,
incoming=incoming,
exclude_uncheckable=exclude_uncheckable,
)
class Payment(NamedTuple):
@@ -52,6 +79,31 @@ class Payment(NamedTuple):
fee: int
memo: str
time: int
bolt11: str
preimage: str
payment_hash: str
extra: Dict
wallet_id: str
@classmethod
def from_row(cls, row: Row):
return cls(
checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64,
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"),
pending=row["pending"],
amount=row["amount"],
fee=row["fee"],
memo=row["memo"],
time=row["time"],
wallet_id=row["wallet"],
)
@property
def tag(self) -> Optional[str]:
return self.extra.get("tag")
@property
def msat(self) -> int:
@@ -69,11 +121,26 @@ class Payment(NamedTuple):
def is_out(self) -> bool:
return self.amount < 0
@property
def is_uncheckable(self) -> bool:
return self.checking_id.startswith("temp_") or self.checking_id.startswith("internal_")
def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
update_payment_status(self.checking_id, pending)
def check_pending(self) -> None:
if self.is_uncheckable:
return
if self.is_out:
pending = WALLET.get_payment_status(self.checking_id)
else:
pending = WALLET.get_invoice_status(self.checking_id)
self.set_pending(pending.pending)
def delete(self) -> None:
from .crud import delete_payment
+211 -46
View File
@@ -1,73 +1,238 @@
from typing import Optional, Tuple
import trio # type: ignore
import json
import httpx
from io import BytesIO
from binascii import unhexlify
from typing import Optional, Tuple, Dict
from urllib.parse import urlparse, parse_qs
from quart import g
from lnurl import LnurlErrorResponse, LnurlWithdrawResponse # type: ignore
from lnbits.bolt11 import decode as bolt11_decode # type: ignore
try:
from typing import TypedDict # type: ignore
except ImportError: # pragma: nocover
from typing_extensions import TypedDict
from lnbits import bolt11
from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import WALLET
from lnbits.wallets.base import PaymentStatus, PaymentResponse
from .crud import get_wallet, create_payment, delete_payment
from .crud import get_wallet, create_payment, delete_payment, check_internal, update_payment_status, get_wallet_payment
def create_invoice(*, wallet_id: str, amount: int, memo: str) -> Tuple[str, str]:
try:
ok, checking_id, payment_request, error_message = WALLET.create_invoice(amount=amount, memo=memo)
except Exception as e:
ok, error_message = False, str(e)
def create_invoice(
*,
wallet_id: str,
amount: int, # in satoshis
memo: str,
description_hash: Optional[bytes] = None,
extra: Optional[Dict] = None,
) -> Tuple[str, str]:
invoice_memo = None if description_hash else memo
storeable_memo = memo
ok, checking_id, payment_request, error_message = WALLET.create_invoice(
amount=amount, memo=invoice_memo, description_hash=description_hash
)
if not ok:
raise Exception(error_message or "Unexpected backend error.")
invoice = bolt11.decode(payment_request)
amount_msat = amount * 1000
create_payment(wallet_id=wallet_id, checking_id=checking_id, amount=amount_msat, memo=memo)
create_payment(
wallet_id=wallet_id,
checking_id=checking_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_msat,
memo=storeable_memo,
extra=extra,
)
return checking_id, payment_request
g.db.commit()
return invoice.payment_hash, payment_request
def pay_invoice(*, wallet_id: str, bolt11: str, max_sat: Optional[int] = None) -> str:
def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[Dict] = None,
description: str = "",
) -> str:
temp_id = f"temp_{urlsafe_short_hash()}"
try:
invoice = bolt11_decode(bolt11)
internal_id = f"internal_{urlsafe_short_hash()}"
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
invoice = bolt11.decode(payment_request)
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.")
# put all parameters that don't change here
PaymentKwargs = TypedDict(
"PaymentKwargs",
{
"wallet_id": str,
"payment_request": str,
"payment_hash": str,
"amount": int,
"memo": str,
"extra": Optional[Dict],
},
)
payment_kwargs: PaymentKwargs = dict(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
memo=description or invoice.description or "",
extra=extra,
)
# check_internal() returns the checking_id of the invoice we're waiting for
internal_checking_id = check_internal(invoice.payment_hash)
if internal_checking_id:
# create a new payment from this wallet
create_payment(checking_id=internal_id, fee=0, pending=False, **payment_kwargs)
else:
# create a temporary payment here so we can check if
# the balance is enough in the next step
fee_reserve = max(1000, int(invoice.amount_msat * 0.01))
create_payment(
wallet_id=wallet_id,
checking_id=temp_id,
amount=-invoice.amount_msat,
fee=-fee_reserve,
memo=temp_id,
create_payment(checking_id=temp_id, fee=-fee_reserve, **payment_kwargs)
# do the balance check
wallet = get_wallet(wallet_id)
assert wallet
if wallet.balance_msat < 0:
g.db.rollback()
raise PermissionError("Insufficient balance.")
else:
g.db.commit()
if internal_checking_id:
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
update_payment_status(checking_id=internal_checking_id, pending=False)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_paid
try:
internal_invoice_paid.send_nowait(internal_checking_id)
except trio.WouldBlock:
pass
else:
# actually pay the external invoice
payment: PaymentResponse = WALLET.pay_invoice(payment_request)
if payment.ok and payment.checking_id:
create_payment(
checking_id=payment.checking_id,
fee=payment.fee_msat,
preimage=payment.preimage,
**payment_kwargs,
)
delete_payment(temp_id)
else:
raise Exception(payment.error_message or "Failed to pay_invoice on backend.")
g.db.commit()
return invoice.payment_hash
async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo: Optional[str] = None) -> None:
_, payment_request = create_invoice(
wallet_id=wallet_id,
amount=res.max_sats,
memo=memo or res.default_description or "",
extra={"tag": "lnurlwallet"},
)
async with httpx.AsyncClient() as client:
await client.get(
res.callback.base,
params={**res.callback.query_params, **{"k1": res.k1, "pr": payment_request}},
)
wallet = get_wallet(wallet_id)
assert wallet, "invalid wallet id"
if wallet.balance_msat < 0:
raise PermissionError("Insufficient balance.")
ok, checking_id, fee_msat, error_message = WALLET.pay_invoice(bolt11)
async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
if ok:
create_payment(
wallet_id=wallet_id,
checking_id=checking_id,
amount=-invoice.amount_msat,
fee=fee_msat,
memo=invoice.description,
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
key = g.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r_int, s_int, order):
# if s > order/2 verification will fail sometimes
# so we must fix it here (see https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147)
if s_int > order // 2:
s_int = order - s_int
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r = int_to_bytes_suitable_der(r_int)
s = int_to_bytes_suitable_der(s_int)
r_len = len(r)
s_len = len(s)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30 .to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02 .to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r)
signature.write(0x02 .to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
async with httpx.AsyncClient() as client:
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text,
)
except Exception as e:
ok, error_message = False, str(e)
delete_payment(temp_id)
def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus:
payment = get_wallet_payment(wallet_id, payment_hash)
if not payment:
return PaymentStatus(None)
if not ok:
raise Exception(error_message or "Unexpected backend error.")
return checking_id
def check_payment(*, checking_id: str) -> str:
pass
return WALLET.get_invoice_status(payment.checking_id)
+357 -124
View File
@@ -1,3 +1,5 @@
/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart, decryptLnurlPayAES */
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
@@ -12,10 +14,8 @@ function generateChart(canvas, payments) {
}
_.each(
payments.slice(0).sort(function (a, b) {
return a.time - b.time
}),
function (tx) {
payments.filter(p => !p.pending).sort((a, b) => a.time - b.time),
tx => {
txs.push({
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
sat: tx.sat
@@ -23,19 +23,15 @@ function generateChart(canvas, payments) {
}
)
_.each(_.groupBy(txs, 'hour'), function (value, day) {
_.each(_.groupBy(txs, 'hour'), (value, day) => {
var income = _.reduce(
value,
function (memo, tx) {
return tx.sat >= 0 ? memo + tx.sat : memo
},
(memo, tx) => (tx.sat >= 0 ? memo + tx.sat : memo),
0
)
var outcome = _.reduce(
value,
function (memo, tx) {
return tx.sat < 0 ? memo + Math.abs(tx.sat) : memo
},
(memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo),
0
)
n = n + income - outcome
@@ -115,30 +111,44 @@ new Vue({
mixins: [windowMixin],
data: function () {
return {
user: LNbits.map.user(window.user),
receive: {
show: false,
status: 'pending',
paymentReq: null,
paymentHash: null,
minMax: [0, 2100000000000000],
lnurl: null,
data: {
amount: null,
memo: ''
}
},
send: {
parse: {
show: false,
invoice: null,
lnurlpay: null,
lnurlauth: null,
data: {
bolt11: ''
request: '',
amount: 0,
comment: ''
},
paymentChecker: null,
camera: {
show: false,
camera: 'auto'
}
},
sendCamera: {
show: false,
camera: 'auto'
},
payments: [],
paymentsTable: {
columns: [
{name: 'memo', align: 'left', label: 'Memo', field: 'memo'},
{
name: 'memo',
align: 'left',
label: 'Memo',
field: 'memo'
},
{
name: 'date',
align: 'left',
@@ -152,6 +162,12 @@ new Vue({
label: 'Amount (sat)',
field: 'sat',
sortable: true
},
{
name: 'fee',
align: 'right',
label: 'Fee (msat)',
field: 'fee'
}
],
pagination: {
@@ -165,32 +181,23 @@ new Vue({
disclaimerDialog: {
show: false,
location: window.location
}
},
balance: 0
}
},
computed: {
formattedBalance: function () {
return LNbits.utils.formatSat(this.balance || this.g.wallet.sat)
},
filteredPayments: function () {
var q = this.paymentsTable.filter
if (!q || q == '') return this.payments
if (!q || q === '') return this.payments
return LNbits.utils.search(this.payments, q)
},
balance: function () {
if (this.payments.length) {
return (
_.pluck(this.payments, 'amount').reduce(function (a, b) {
return a + b
}, 0) / 1000
)
}
return this.g.wallet.sat
},
fbalance: function () {
return LNbits.utils.formatSat(this.balance)
},
canPay: function () {
if (!this.send.invoice) return false
return this.send.invoice.sat <= this.balance
if (!this.parse.invoice) return false
return this.parse.invoice.sat <= this.balance
},
pendingPaymentsExist: function () {
return this.payments
@@ -198,105 +205,201 @@ new Vue({
: false
}
},
filters: {
msatoshiFormat: function (value) {
return LNbits.utils.formatSat(value / 1000)
}
},
methods: {
closeCamera: function () {
this.sendCamera.show = false
this.parse.camera.show = false
},
showCamera: function () {
this.sendCamera.show = true
this.parse.camera.show = true
},
showChart: function () {
this.paymentsChart.show = true
this.$nextTick(function () {
this.$nextTick(() => {
generateChart(this.$refs.canvas, this.payments)
})
},
showReceiveDialog: function () {
this.receive = {
show: true,
status: 'pending',
paymentReq: null,
data: {
amount: null,
memo: ''
},
paymentChecker: null
}
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = null
this.receive.data.memo = null
this.receive.paymentChecker = null
this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null
},
showSendDialog: function () {
this.send = {
show: true,
invoice: null,
data: {
bolt11: ''
},
paymentChecker: null
}
showParseDialog: function () {
this.parse.show = true
this.parse.invoice = null
this.parse.lnurlpay = null
this.parse.lnurlauth = null
this.parse.data.request = ''
this.parse.data.comment = ''
this.parse.data.paymentChecker = null
this.parse.camera.show = false
},
closeReceiveDialog: function () {
var checker = this.receive.paymentChecker
setTimeout(function () {
clearInterval(checker)
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 10000)
},
closeSendDialog: function () {
this.sendCamera.show = false
var checker = this.send.paymentChecker
setTimeout(function () {
clearInterval(checker)
}, 1000)
closeParseDialog: function () {
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 10000)
},
onPaymentReceived: function (paymentHash) {
this.fetchPayments()
this.fetchBalance()
if (this.receive.paymentHash === paymentHash) {
this.receive.show = false
this.receive.paymentHash = null
clearInterval(this.receive.paymentChecker)
}
},
createInvoice: function () {
var self = this
this.receive.status = 'loading'
LNbits.api
.createInvoice(
this.g.wallet,
this.receive.data.amount,
this.receive.data.memo
this.receive.data.memo,
this.receive.lnurl && this.receive.lnurl.callback
)
.then(function (response) {
self.receive.status = 'success'
self.receive.paymentReq = response.data.payment_request
.then(response => {
this.receive.status = 'success'
this.receive.paymentReq = response.data.payment_request
this.receive.paymentHash = response.data.payment_hash
self.receive.paymentChecker = setInterval(function () {
LNbits.api
.getPayment(self.g.wallet, response.data.checking_id)
.then(function (response) {
if (response.data.paid) {
self.fetchPayments()
self.receive.show = false
clearInterval(self.receive.paymentChecker)
}
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
}
if (typeof response.data.lnurl_response === 'string') {
// failure
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
caption: response.data.lnurl_response
})
}, 2000)
return
} else if (response.data.lnurl_response === true) {
// success
this.$q.notify({
timeout: 5000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true
})
}
}
clearInterval(this.receive.paymentChecker)
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 40000)
this.receive.paymentChecker = setInterval(() => {
let hash = response.data.payment_hash
LNbits.api.getPayment(this.g.wallet, hash).then(response => {
if (response.data.paid) {
this.onPaymentReceived(hash)
}
})
}, 5000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
self.receive.status = 'pending'
.catch(err => {
LNbits.utils.notifyApiError(err)
this.receive.status = 'pending'
})
},
decodeQR: function (res) {
this.send.data.bolt11 = res
this.decodeInvoice()
this.sendCamera.show = false
this.parse.data.request = res
this.decodeRequest()
this.parse.camera.show = false
},
decodeInvoice: function () {
if (this.send.data.bolt11.startsWith('lightning:')) {
this.send.data.bolt11 = this.send.data.bolt11.slice(10)
decodeRequest: function () {
this.parse.show = true
if (this.parse.data.request.startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10)
} else if (this.parse.data.request.startsWith('lnurl:')) {
this.parse.data.request = this.parse.data.request.slice(6)
} else if (this.parse.data.request.indexOf('lightning=lnurl1') !== -1) {
this.parse.data.request = this.parse.data.request
.split('lightning=')[1]
.split('&')[0]
}
if (this.parse.data.request.toLowerCase().startsWith('lnurl1')) {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
this.parse.show = false
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription
this.receive.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = {
domain: data.domain,
callback: data.callback,
fixed: data.fixed
}
}
})
return
}
let invoice
try {
invoice = decode(this.send.data.bolt11)
invoice = decode(this.parse.data.request)
} catch (error) {
this.$q.notify({
timeout: 3000,
type: 'warning',
message: error + '.',
caption: '400 BAD REQUEST',
icon: null
caption: '400 BAD REQUEST'
})
this.parse.show = false
return
}
@@ -306,13 +409,13 @@ new Vue({
fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000)
}
_.each(invoice.data.tags, function (tag) {
_.each(invoice.data.tags, tag => {
if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description == 'payment_hash') {
if (tag.description === 'payment_hash') {
cleanInvoice.hash = tag.value
} else if (tag.description == 'description') {
} else if (tag.description === 'description') {
cleanInvoice.description = tag.value
} else if (tag.description == 'expiry') {
} else if (tag.description === 'expiry') {
var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000
)
@@ -325,68 +428,191 @@ new Vue({
}
})
this.send.invoice = Object.freeze(cleanInvoice)
this.parse.invoice = Object.freeze(cleanInvoice)
},
payInvoice: function () {
var self = this
dismissPaymentMsg = this.$q.notify({
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...',
icon: null
message: 'Processing payment...'
})
LNbits.api
.payInvoice(this.g.wallet, this.send.data.bolt11)
.then(function (response) {
self.send.paymentChecker = setInterval(function () {
.payInvoice(this.g.wallet, this.parse.data.request)
.then(response => {
clearInterval(this.parse.paymentChecker)
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 40000)
this.parse.paymentChecker = setInterval(() => {
LNbits.api
.getPayment(self.g.wallet, response.data.checking_id)
.then(function (res) {
.getPayment(this.g.wallet, response.data.payment_hash)
.then(res => {
if (res.data.paid) {
self.send.show = false
clearInterval(self.send.paymentChecker)
this.parse.show = false
clearInterval(this.parse.paymentChecker)
dismissPaymentMsg()
self.fetchPayments()
this.fetchPayments()
this.fetchBalance()
}
})
}, 2000)
})
.catch(function (error) {
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(error)
LNbits.utils.notifyApiError(err)
})
},
payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...'
})
LNbits.api
.payLnurl(
this.g.wallet,
this.parse.lnurlpay.callback,
this.parse.lnurlpay.description_hash,
this.parse.data.amount * 1000,
this.parse.lnurlpay.description.slice(0, 120),
this.parse.data.comment
)
.then(response => {
this.parse.show = false
clearInterval(this.parse.paymentChecker)
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 40000)
this.parse.paymentChecker = setInterval(() => {
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(res => {
if (res.data.paid) {
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
this.fetchPayments()
this.fetchBalance()
// show lnurlpay success action
if (response.data.success_action) {
switch (response.data.success_action.tag) {
case 'url':
this.$q.notify({
message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`,
caption: response.data.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'message':
this.$q.notify({
message: response.data.success_action.message,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'aes':
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
response.data.success_action,
payment.preimage
)
)
.then(value => {
this.$q.notify({
message: value,
caption: response.data.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
})
break
}
}
}
})
}, 2000)
})
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
})
},
authLnurl: function () {
let dismissAuthMsg = this.$q.notify({
timeout: 10,
message: 'Performing authentication...'
})
LNbits.api
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
.then(response => {
dismissAuthMsg()
this.$q.notify({
message: `Authentication successful.`,
type: 'positive',
timeout: 3500
})
this.parse.show = false
})
.catch(err => {
dismissAuthMsg()
if (err.response.data.reason) {
this.$q.notify({
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`,
caption: err.response.data.reason,
type: 'warning',
timeout: 5000
})
} else {
LNbits.utils.notifyApiError(err)
}
})
},
deleteWallet: function (walletId, user) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?')
.onOk(function () {
.onOk(() => {
LNbits.href.deleteWallet(walletId, user)
})
},
fetchPayments: function (checkPending) {
var self = this
return LNbits.api
.getPayments(this.g.wallet, checkPending)
.then(function (response) {
self.payments = response.data
.map(function (obj) {
.then(response => {
this.payments = response.data
.map(obj => {
return LNbits.map.payment(obj)
})
.sort(function (a, b) {
.sort((a, b) => {
return b.time - a.time
})
})
},
fetchBalance: function () {
LNbits.api.getWallet(this.g.wallet).then(response => {
this.balance = Math.round(response.data.balance / 1000)
EventHub.$emit('update-wallet-balance', [
this.g.wallet.id,
this.balance
])
})
},
checkPendingPayments: function () {
var dismissMsg = this.$q.notify({
timeout: 0,
message: 'Checking pending transactions...',
icon: null
message: 'Checking pending transactions...'
})
this.fetchPayments(true).then(function () {
this.fetchPayments(true).then(() => {
dismissMsg()
})
},
@@ -396,14 +622,16 @@ new Vue({
},
watch: {
payments: function () {
EventHub.$emit('update-wallet-balance', [this.g.wallet.id, this.balance])
this.fetchBalance()
}
},
created: function () {
this.fetchBalance()
this.fetchPayments()
setTimeout(this.checkPendingPayments(), 1200)
},
mounted: function () {
// show disclaimer
if (
this.$refs.disclaimer &&
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
@@ -411,5 +639,10 @@ new Vue({
this.disclaimerDialog.show = true
this.$q.localStorage.set('lnbits.disclaimerShown', true)
}
// listen to incoming payments
LNbits.events.onInvoicePaid(this.g.wallet, payment =>
this.onPaymentReceived(payment.payment_hash)
)
}
})
+22
View File
@@ -0,0 +1,22 @@
import trio # type: ignore
from typing import List
from lnbits.tasks import register_invoice_listener
sse_listeners: List[trio.MemorySendChannel] = []
async def register_listeners():
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(5)
register_invoice_listener(invoice_paid_chan_send)
await wait_for_paid_invoices(invoice_paid_chan_recv)
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
for send_channel in sse_listeners:
try:
send_channel.send_nowait(payment)
except trio.WouldBlock:
print("removing sse listener", send_channel)
sse_listeners.remove(send_channel)
+82 -14
View File
@@ -4,22 +4,69 @@
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="Create an invoice (incoming)">
<q-expansion-item
group="api"
dense
expand-separator
label="Get wallet details"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.adminkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "name": &lt;string&gt;, "balance":
&lt;int&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl {{ request.url_root }}api/v1/wallet -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create an invoice (incoming)"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 CREATED (application/json)</h5>
<code>{"checking_id": &lt;string&gt;, "payment_request": &lt;string&gt;}</code>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"payment_hash": &lt;string&gt;, "payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code>
<code
>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Pay an invoice (outgoing)">
<q-expansion-item
group="api"
dense
expand-separator
label="Pay an invoice (outgoing)"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
@@ -27,24 +74,45 @@
<code>{"X-Api-Key": "{{ wallet.adminkey }}"}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 CREATED (application/json)</h5>
<code>{"checking_id": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": true, "bolt11": &lt;string&gt;}' -H "X-Api-Key: {{ wallet.adminkey }}"</i> -H "Content-type: application/json"</code>
<code
>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": true,
"bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Check an invoice (incoming or outgoing)"
class="q-pb-md">
<q-expansion-item
group="api"
dense
expand-separator
label="Check an invoice (incoming or outgoing)"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code><span class="text-light-blue">GET</span> /api/v1/payments/&lt;checking_id&gt;</code>
<code
><span class="text-light-blue">GET</span>
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X GET {{ request.url_root }}api/v1/payments/&lt;checking_id&gt; -H "X-Api-Key: {{ wallet.inkey }}"</i> -H "Content-type: application/json"</code>
<code
>curl -X GET {{ request.url_root
}}api/v1/payments/&lt;payment_hash&gt; -H "X-Api-Key:
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
+51 -39
View File
@@ -1,42 +1,54 @@
{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block scripts %}
{{ window_vars(user) }}
{% assets filters='rjsmin', output='__bundle__/core/extensions.js',
'core/js/extensions.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-6 col-md-4 col-lg-3" v-for="extension in g.extensions" :key="extension.code">
<q-card>
<q-card-section>
<q-icon :name="extension.icon" color="grey-5" style="font-size: 4rem;"></q-icon>
{% raw %}
<h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5>
{{ extension.shortDescription }}
{% endraw %}
</q-card-section>
<q-separator></q-separator>
<q-card-actions>
<div v-if="extension.isEnabled">
<q-btn flat color="deep-purple"
type="a" :href="[extension.url, '?usr=', g.user.id].join('')">Open</q-btn>
<q-btn flat color="grey-5"
type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&disable=', extension.code].join('')"> Disable</q-btn>
</div>
<q-btn v-else flat color="deep-purple"
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
<script src="/core/static/js/extensions.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div
class="col-6 col-md-4 col-lg-3"
v-for="extension in g.extensions"
:key="extension.code"
>
<q-card>
<q-card-section>
<q-icon
:name="extension.icon"
color="grey-5"
style="font-size: 4rem"
></q-icon>
{% raw %}
<h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5>
{{ extension.shortDescription }} {% endraw %}
</q-card-section>
<q-separator></q-separator>
<q-card-actions>
<div v-if="extension.isEnabled">
<q-btn
flat
color="deep-purple"
type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')">
Enable</q-btn>
</q-card-actions>
</q-card>
</div>
:href="[extension.url, '?usr=', g.user.id].join('')"
>Open</q-btn
>
<q-btn
flat
color="grey-5"
type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&disable=', extension.code].join('')"
>
Disable</q-btn
>
</div>
<q-btn
v-else
flat
color="deep-purple"
type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')"
>
Enable</q-btn
>
</q-card-actions>
</q-card>
</div>
</div>
{% endblock %}
+104 -66
View File
@@ -1,72 +1,110 @@
{% extends "public.html" %}
{% extends "public.html" %} {% block scripts %}
<script src="/core/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
{% if lnurl %}
<q-btn
unelevated
color="deep-purple"
@click="processing"
type="a"
href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}"
>
Press to claim bitcoin
</q-btn>
{% else %}
<q-form @submit="createWallet" class="q-gutter-md">
<q-input
filled
dense
v-model="walletName"
label="Name your LNbits wallet *"
></q-input>
<q-btn
unelevated
color="deep-purple"
:disable="walletName == ''"
type="submit"
>Add a new wallet</q-btn
>
</q-form>
{% endif %}
</q-card-section>
</q-card>
{% block scripts %}
{% assets filters='rjsmin', output='__bundle__/core/index.js',
'core/js/index.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
{% if lnurl %}
<q-btn unelevated
color="deep-purple"
@click="processing"
type="a" href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}">
Press to claim bitcoin
</q-btn>
{% else %}
<q-form @submit="createWallet" class="q-gutter-md">
<q-input filled dense
v-model="walletName"
label="Name your LNbits wallet *"
></q-input>
<q-btn unelevated
color="deep-purple"
:disable="walletName == ''"
type="submit">Add a new wallet</q-btn>
</q-form>
{% endif %}
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<h3 class="q-my-none"><strong>LN</strong>bits</h3>
<h5 class="q-my-md">Free and open-source lightning wallet</h5>
<p>Easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself!</p>
<p>You can run LNbits for yourself, or easily offer a custodian solution for others.</p>
<p>Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool.</p>
<p>Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.</p>
<div class="row q-mt-md q-gutter-sm">
<q-btn outline
color="grey"
type="a" href="https://github.com/lnbits/lnbits" target="_blank" rel="noopener">View project in GitHub</q-btn>
<q-btn outline
color="grey"
type="a" href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK" target="_blank" rel="noopener">Donate</q-btn>
</div>
</q-card-section>
</q-card>
</div>
<!-- Ads -->
<div class="col-12 col-md-3 col-lg-3">
<div class="row q-col-gutter-lg justify-center">
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn flat color="purple" label="Supporters" type="a" href="mailto:lnbits@pm.me" class="full-width"></q-btn>
<a href="https://github.com/rootzoll/raspiblitz">
<q-img contain :src="($q.dark.isActive) ? '{{ url_for('static', filename='images/raspiblitz-negative.svg') }}' : '{{ url_for('static', filename='images/raspiblitz.svg') }}'" style="height: 90px;"></q-img>
</a>
<q-card>
<q-card-section>
<h3 class="q-my-none"><strong>LN</strong>bits</h3>
<h5 class="q-my-md">Free and open-source lightning wallet</h5>
<p>
Easy to set up and lightweight, LNbits can run on any
lightning-network funding source, currently supporting LND,
c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself!
</p>
<p>
You can run LNbits for yourself, or easily offer a custodian solution
for others.
</p>
<p>
Each wallet has its own API keys and there is no limit to the number
of wallets you can make. Being able to partition funds makes LNbits a
useful tool for money management and as a development tool.
</p>
<p>
Extensions add extra functionality to LNbits so you can experiment
with a range of cutting-edge technologies on the lightning network. We
have made developing extensions as easy as possible, and as a free and
open-source project, we encourage people to develop and submit their
own.
</p>
<div class="row q-mt-md q-gutter-sm">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener"
>View project in GitHub</q-btn
>
<q-btn
outline
color="grey"
type="a"
href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
target="_blank"
rel="noopener"
>Donate</q-btn
>
</div>
</q-card-section>
</q-card>
</div>
<!-- Ads -->
<div class="col-12 col-md-3 col-lg-3">
<div class="row q-col-gutter-lg justify-center">
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn
flat
color="purple"
label="Supporters"
type="a"
href="mailto:lnbits@pm.me"
class="full-width"
></q-btn>
<a href="https://github.com/rootzoll/raspiblitz">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/raspiblitz-negative.svg' : '/static/images/raspiblitz.svg'"
style="height: 90px"
></q-img>
</a>
</div>
</div>
</div>
</div>
{% endblock %}
+527 -243
View File
@@ -1,259 +1,543 @@
{% extends "base.html" %}
<!---->
{% from "macros.jinja" import window_vars with context %}
{% block styles %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='vendor/vue-qrcode-reader@2.2.0/vue-qrcode-reader.min.css') }}">
<!---->
{% block scripts %} {{ window_vars(user, wallet) }}
<script src="/core/static/js/wallet.js"></script>
{% endblock %}
{% block scripts %}
{{ window_vars(user, wallet) }}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
{% assets filters='rjsmin', output='__bundle__/core/chart.js',
'vendor/moment@2.25.1/moment.min.js',
'vendor/chart.js@2.9.3/chart.min.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% assets filters='rjsmin', output='__bundle__/core/wallet.js',
'vendor/bolt11/utils.js',
'vendor/bolt11/decoder.js',
'vendor/vue-qrcode-reader@2.2.0/vue-qrcode-reader.min.js',
'core/js/wallet.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% endblock %}
<!---->
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
<!---->
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<h3 class="q-my-none"><strong>{% raw %}{{ fbalance }}{% endraw %}</strong> sat</h3>
</q-card-section>
<div class="row q-pb-md q-px-md q-col-gutter-md">
<div class="col">
<q-btn unelevated
color="purple"
class="full-width"
@click="showSendDialog">Send</q-btn>
</div>
<div class="col">
<q-btn unelevated
color="deep-purple"
class="full-width"
@click="showReceiveDialog">Receive</q-btn>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<h3 class="q-my-none">
<strong>{% raw %}{{ formattedBalance }}{% endraw %}</strong> sat
</h3>
</q-card-section>
<div class="row q-pb-md q-px-md q-col-gutter-md">
<div class="col">
<q-btn
unelevated
color="deep-purple"
class="full-width"
@click="showParseDialog"
>Paste Request</q-btn
>
</div>
</q-card>
<div class="col">
<q-btn
unelevated
color="deep-purple"
class="full-width"
@click="showReceiveDialog"
>Create Invoice</q-btn
>
</div>
<div class="col">
<q-btn
unelevated
color="purple"
icon="photo_camera"
@click="showCamera"
>scan
<q-tooltip>Use camera to scan an invoice/QR</q-tooltip>
</q-btn>
</div>
</div>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Transactions</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Transactions</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
<q-tooltip>Check pending</q-tooltip>
</q-btn>-->
<q-btn dense flat round icon="show_chart" color="grey" @click="showChart">
<q-tooltip>Show chart</q-tooltip>
</q-btn>
</div>
<q-btn
dense
flat
round
icon="show_chart"
color="grey"
@click="showChart"
>
<q-tooltip>Show chart</q-tooltip>
</q-btn>
</div>
<q-input v-if="payments.length > 10" filled dense clearable
v-model="paymentsTable.filter"
debounce="300" placeholder="Search by memo, amount" class="q-mb-md">
</q-input>
<q-table dense flat
:data="filteredPayments"
row-key="payhash"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props">{{ col.label }}</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="lnbits__q-table__icon-td">
<q-icon v-if="props.row.isPaid" size="14px"
:name="(props.row.sat < 0) ? 'call_made' : 'call_received'"
:color="(props.row.sat < 0) ? 'pink' : 'green'"></q-icon>
<q-icon v-else name="settings_ethernet" color="grey">
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
<q-td key="memo" :props="props">
{{ props.row.memo }}
</q-td>
<q-td auto-width key="date" :props="props">
{{ props.row.date }}
</q-td>
<q-td auto-width key="sat" :props="props">
{{ props.row.fsat }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
<q-table
dense
flat
:data="filteredPayments"
row-key="payment_hash"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props"
>{{ col.label }}</q-th
>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.isPaid"
size="14px"
:name="props.row.isOut ? 'call_made' : 'call_received'"
:color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon>
<q-icon
v-else
name="settings_ethernet"
color="grey"
@click="props.expand = !props.expand"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
<q-td
key="memo"
:props="props"
style="white-space: normal; word-break: break-all"
>
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
<a
class="inherit"
:href="['/', props.row.tag, '?usr=', user.id].join('')"
>
#{{ props.row.tag }}
</a>
</q-badge>
{{ props.row.memo }}
</q-td>
<q-td auto-width key="date" :props="props">
<q-tooltip>{{ props.row.date }}</q-tooltip>
{{ props.row.dateFrom }}
</q-td>
<q-td auto-width key="sat" :props="props">
{{ props.row.fsat }}
</q-td>
<q-td auto-width key="fee" :props="props">
{{ props.row.fee }}
</q-td>
</q-tr>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn flat color="grey" @click="exportCSV" class="float-right">Renew keys</q-btn>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">LNbits wallet</h6>
<strong>Wallet name: </strong><em>{{ wallet.name }}</em><br>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br>
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br>
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "core/_api_docs.html" %}
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="remove_circle"
label="Delete wallet">
<q-card>
<q-card-section>
<p>This whole wallet will be deleted, the funds will be <strong>UNRECOVERABLE</strong>.</p>
<q-btn unelevated
color="red-10"
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')">Delete wallet</q-btn>
</q-card-section>
<q-dialog v-model="props.expand" :props="props">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
Invoice waiting to be paid
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div v-if="props.row.bolt11" class="text-center q-mb-lg">
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="props.row.bolt11"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
Payment Received
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
Payment Sent
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
Outgoing payment pending
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card v-if="!receive.paymentReq" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createInvoice" class="q-gutter-md">
<q-input filled dense
v-model.number="receive.data.amount"
type="number"
label="Amount (sat) *"></q-input>
<q-input filled dense
v-model.trim="receive.data.memo"
label="Memo"
placeholder="LNbits invoice"></q-input>
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn unelevated
color="deep-purple"
:disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit">Create invoice</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<q-spinner v-if="receive.status == 'loading'" color="deep-purple" size="2.55em"></q-spinner>
</q-form>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode :value="receive.paymentReq" :options="{width: 340}" class="rounded-borders"></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)">Copy invoice</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="send.show" position="top" @hide="closeSendDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!send.invoice">
<q-form v-if="!sendCamera.show" @submit="decodeInvoice" class="q-gutter-md">
<q-input filled dense
v-model.trim="send.data.bolt11"
type="textarea"
label="Paste an invoice *">
<template v-slot:after>
<q-btn round dense flat icon="photo_camera" @click="showCamera">
<q-tooltip>Use camera to scan an invoice</q-tooltip>
</q-btn>
</template>
</q-input>
<div class="row q-mt-lg">
<q-btn unelevated
color="deep-purple"
:disable="send.data.bolt11 == ''"
type="submit">Read invoice</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
</div>
<div v-else>
{% raw %}
<h6 class="q-my-none">{{ send.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator>
<p style="word-break: break-all">
<strong>Memo:</strong> {{ send.invoice.description }}<br>
<strong>Expire date:</strong> {{ send.invoice.expireDate }}<br>
<strong>Hash:</strong> {{ send.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated
color="deep-purple"
@click="payInvoice">Send satoshis</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black">Not enough funds!</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="paymentsChart.show" position="top">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-dialog>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</q-dialog>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn flat color="grey" @click="exportCSV" class="float-right"
>Renew keys</q-btn
>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">LNbits wallet</h6>
<strong>Wallet name: </strong><em>{{ wallet.name }}</em><br />
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "core/_api_docs.html" %}
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="remove_circle"
label="Delete wallet"
>
<q-card>
<q-card-section>
<p>
This whole wallet will be deleted, the funds will be
<strong>UNRECOVERABLE</strong>.
</p>
<q-btn
unelevated
color="red-10"
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
>Delete wallet</q-btn
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
{% if service_fee > 0 %}
<div ref="disclaimer"></div>
<q-dialog v-model="disclaimerDialog.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-deep-purple">Warning</h6>
<p>Login functionality to be released in v0.2, for now, <strong>make sure you bookmark this page for future access to your wallet</strong>!</p>
<p>This service is in BETA, and we hold no responsibility for people losing access to funds. To encourage you to run your own LNbits installation, any balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will incur a charge of <strong>{{ service_fee }}% service fee</strong> per week.</p>
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
{% raw %}
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
</p>
<q-input
filled
dense
v-model.number="receive.data.amount"
type="number"
label="Amount (sat) *"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
<q-input
filled
dense
v-model.trim="receive.data.memo"
label="Memo"
placeholder="LNbits invoice"
></q-input>
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn
unelevated
color="deep-purple"
:disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit"
>
<span v-if="receive.lnurl">
Withdraw from {{receive.lnurl.domain}}
</span>
<span v-else> Create invoice </span>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<q-spinner
v-if="receive.status == 'loading'"
color="deep-purple"
size="2.55em"
></q-spinner>
</q-form>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="receive.paymentReq"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
{% endraw %}
</q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="parse.invoice">
{% raw %}
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator>
<p class="text-wrap">
<strong>Description:</strong> {{ parse.invoice.description }}<br />
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ parse.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="deep-purple" @click="payInvoice">Pay</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black"
>Not enough funds!</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
<div v-else-if="parse.lnurlauth">
{% raw %}
<q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6">
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
</p>
<q-separator class="q-my-sm"></q-separator>
<p>
For every website and for every LNbits wallet, a new keypair will be
deterministically generated so your identity can't be tied to your
LNbits wallet or linked across websites. No other data will be shared
with {{ parse.lnurlauth.domain }}.
</p>
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
<p class="q-mx-xl">
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
</p>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(disclaimerDialog.location.href)">Copy wallet URL</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">I understand</q-btn>
<q-btn unelevated color="deep-purple" type="submit">Login</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-card>
</q-dialog>
{% endif %}
{% endblock %}
</q-form>
{% endraw %}
</div>
<div v-else-if="parse.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
parse.lnurlpay.maxSendable | msatoshiFormat }} sat
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.domain }}</b> is requesting <br />
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b> sat
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<q-separator class="q-my-sm"></q-separator>
<div class="row">
<p class="col text-justify text-italic">
{{ parse.lnurlpay.description }}
</p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" />
</p>
</div>
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.number="parse.data.amount"
type="number"
label="Amount (sat) *"
:min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay.fixed"
></q-input>
</div>
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
<q-input
filled
dense
v-model.number="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="deep-purple" type="submit"
>Send satoshis</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else>
<q-form
v-if="!parse.camera.show"
@submit="decodeRequest"
class="q-gutter-md"
>
<q-input
filled
dense
v-model.trim="parse.data.request"
type="textarea"
label="Paste an invoice, payment request or lnurl code *"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="deep-purple"
:disable="parse.data.request == ''"
type="submit"
>Read</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
Cancel
</q-btn>
</div>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="parse.camera.show">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="paymentsChart.show">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
{% if service_fee > 0 %}
<div ref="disclaimer"></div>
<q-dialog v-model="disclaimerDialog.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-deep-purple">Warning</h6>
<p>
Login functionality to be released in v0.2, for now,
<strong
>make sure you bookmark this page for future access to your
wallet</strong
>!
</p>
<p>
This service is in BETA, and we hold no responsibility for people losing
access to funds. To encourage you to run your own LNbits installation, any
balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will
incur a charge of <strong>{{ service_fee }}% service fee</strong> per
week.
</p>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(disclaimerDialog.location.href)"
>Copy wallet URL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>I understand</q-btn
>
</div>
</q-card>
</q-dialog>
{% endif %} {% endblock %}
+323 -37
View File
@@ -1,89 +1,375 @@
from flask import g, jsonify, request
import trio # type: ignore
import json
import lnurl # type: ignore
import httpx
import traceback
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
from quart import g, jsonify, request, make_response
from http import HTTPStatus
from binascii import unhexlify
from typing import Dict, Union
from lnbits.core import core_app
from lnbits import bolt11
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.settings import WALLET
from ..services import create_invoice, pay_invoice
from .. import core_app
from ..services import create_invoice, pay_invoice, perform_lnurlauth
from ..crud import delete_expired_invoices
from ..tasks import sse_listeners
@core_app.route("/api/v1/wallet", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_wallet():
return (
jsonify(
{
"id": g.wallet.id,
"name": g.wallet.name,
"balance": g.wallet.balance_msat,
}
),
HTTPStatus.OK,
)
@core_app.route("/api/v1/payments", methods=["GET"])
@api_check_wallet_key("invoice")
def api_payments():
async def api_payments():
if "check_pending" in request.args:
g.wallet.delete_expired_payments()
delete_expired_invoices()
for payment in g.wallet.get_payments(include_all_pending=True):
if payment.is_out:
payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
else:
payment.set_pending(WALLET.get_invoice_status(payment.checking_id).pending)
for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True):
payment.check_pending()
return jsonify(g.wallet.get_payments()), HTTPStatus.OK
return jsonify(g.wallet.get_payments(pending=True)), HTTPStatus.OK
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"amount": {"type": "integer", "min": 1, "required": True},
"memo": {"type": "string", "empty": False, "required": True},
"memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
"lnurl_callback": {"type": "string", "nullable": True, "required": False},
}
)
def api_payments_create_invoice():
async def api_payments_create_invoice():
if "description_hash" in g.data:
description_hash = unhexlify(g.data["description_hash"])
memo = ""
else:
description_hash = b""
memo = g.data["memo"]
try:
checking_id, payment_request = create_invoice(
wallet_id=g.wallet.id, amount=g.data["amount"], memo=g.data["memo"]
payment_hash, payment_request = create_invoice(
wallet_id=g.wallet.id, amount=g.data["amount"], memo=memo, description_hash=description_hash
)
except Exception as e:
g.db.rollback()
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.CREATED
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
if g.data.get("lnurl_callback"):
async with httpx.AsyncClient() as client:
try:
r = await client.get(
g.data["lnurl_callback"],
params={"pr": payment_request},
timeout=10,
)
if r.is_error:
lnurl_response = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
lnurl_response = resp["reason"]
else:
lnurl_response = True
except (httpx.ConnectError, httpx.RequestError):
lnurl_response = False
return (
jsonify(
{
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
# maintain backwards compatibility with API clients:
"checking_id": invoice.payment_hash,
"lnurl_response": lnurl_response,
}
),
HTTPStatus.CREATED,
)
@api_check_wallet_key("admin")
@api_validate_post_request(schema={"bolt11": {"type": "string", "empty": False, "required": True}})
def api_payments_pay_invoice():
async def api_payments_pay_invoice():
try:
checking_id = pay_invoice(wallet_id=g.wallet.id, bolt11=g.data["bolt11"])
payment_hash = pay_invoice(wallet_id=g.wallet.id, payment_request=g.data["bolt11"])
except ValueError as e:
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
except PermissionError as e:
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
except Exception as exc:
traceback.print_exc(7)
g.db.rollback()
return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR
return jsonify({"checking_id": checking_id}), HTTPStatus.CREATED
return (
jsonify(
{
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
),
HTTPStatus.CREATED,
)
@core_app.route("/api/v1/payments", methods=["POST"])
@api_validate_post_request(schema={"out": {"type": "boolean", "required": True}})
def api_payments_create():
async def api_payments_create():
if g.data["out"] is True:
return api_payments_pay_invoice()
return api_payments_create_invoice()
return await api_payments_pay_invoice()
return await api_payments_create_invoice()
@core_app.route("/api/v1/payments/<checking_id>", methods=["GET"])
@core_app.route("/api/v1/payments/lnurl", methods=["POST"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"description_hash": {"type": "string", "empty": False, "required": True},
"callback": {"type": "string", "empty": False, "required": True},
"amount": {"type": "number", "empty": False, "required": True},
"comment": {"type": "string", "nullable": True, "empty": True, "required": False},
"description": {"type": "string", "nullable": True, "empty": True, "required": False},
}
)
async def api_payments_pay_lnurl():
domain = urlparse(g.data["callback"]).netloc
async with httpx.AsyncClient() as client:
try:
r = await client.get(
g.data["callback"],
params={"amount": g.data["amount"], "comment": g.data["comment"]},
timeout=40,
)
if r.is_error:
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
except (httpx.ConnectError, httpx.RequestError):
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
params = json.loads(r.text)
if params.get("status") == "ERROR":
return jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}), HTTPStatus.BAD_REQUEST
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != g.data["amount"]:
return (
jsonify(
{
"message": f"{domain} returned an invalid invoice. Expected {g.data['amount']} msat, got {invoice.amount_msat}."
}
),
HTTPStatus.BAD_REQUEST,
)
if invoice.description_hash != g.data["description_hash"]:
return (
jsonify(
{
"message": f"{domain} returned an invalid invoice. Expected description_hash == {g.data['description_hash']}, got {invoice.description_hash}."
}
),
HTTPStatus.BAD_REQUEST,
)
try:
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if g.data["comment"]:
extra["comment"] = g.data["comment"]
payment_hash = pay_invoice(
wallet_id=g.wallet.id,
payment_request=params["pr"],
description=g.data.get("description", ""),
extra=extra,
)
except Exception as exc:
traceback.print_exc(7)
g.db.rollback()
return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR
return (
jsonify(
{
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
),
HTTPStatus.CREATED,
)
@core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"])
@api_check_wallet_key("invoice")
def api_payment(checking_id):
payment = g.wallet.get_payment(checking_id)
async def api_payment(payment_hash):
payment = g.wallet.get_payment(payment_hash)
if not payment:
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
elif not payment.pending:
return jsonify({"paid": True}), HTTPStatus.OK
return jsonify({"paid": True, "preimage": payment.preimage}), HTTPStatus.OK
try:
if payment.is_out:
is_paid = not WALLET.get_payment_status(checking_id).pending
elif payment.is_in:
is_paid = not WALLET.get_invoice_status(checking_id).pending
payment.check_pending()
except Exception:
return jsonify({"paid": False}), HTTPStatus.OK
if is_paid:
payment.set_pending(False)
return jsonify({"paid": True}), HTTPStatus.OK
return jsonify({"paid": not payment.pending, "preimage": payment.preimage}), HTTPStatus.OK
return jsonify({"paid": False}), HTTPStatus.OK
@core_app.route("/api/v1/payments/sse", methods=["GET"])
@api_check_wallet_key("invoice", accept_querystring=True)
async def api_payments_sse():
g.db.close()
this_wallet_id = g.wallet.id
send_payment, receive_payment = trio.open_memory_channel(0)
print("adding sse listener", send_payment)
sse_listeners.append(send_payment)
send_event, event_to_send = trio.open_memory_channel(0)
async def payment_received() -> None:
async for payment in receive_payment:
if payment.wallet_id == this_wallet_id:
await send_event.send(("payment-received", payment))
async def repeat_keepalive():
await trio.sleep(1)
while True:
await send_event.send(("keepalive", ""))
await trio.sleep(25)
g.nursery.start_soon(payment_received)
g.nursery.start_soon(repeat_keepalive)
async def send_events():
try:
async for typ, data in event_to_send:
message = [f"event: {typ}".encode("utf-8")]
if data:
jdata = json.dumps(dict(data._asdict(), pending=False))
message.append(f"data: {jdata}".encode("utf-8"))
yield b"\n".join(message) + b"\r\n\r\n"
except trio.Cancelled:
return
response = await make_response(
send_events(),
{
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
"Transfer-Encoding": "chunked",
},
)
response.timeout = None
return response
@core_app.route("/api/v1/lnurlscan/<code>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_lnurlscan(code: str):
try:
url = lnurl.Lnurl(code)
except ValueError:
return jsonify({"message": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
domain = urlparse(url.url).netloc
# params is what will be returned to the client
params: Dict = {"domain": domain}
if url.is_login:
params.update(kind="auth")
params.update(callback=url.url) # with k1 already in it
lnurlauth_key = g.wallet.lnurlauth_key(domain)
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
async with httpx.AsyncClient() as client:
r = await client.get(url.url, timeout=40)
if r.is_error:
return (
jsonify({"domain": domain, "message": "failed to get parameters"}),
HTTPStatus.SERVICE_UNAVAILABLE,
)
try:
jdata = json.loads(r.text)
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
return (
jsonify({"domain": domain, "message": f"got invalid response '{r.text[:200]}'"}),
HTTPStatus.SERVICE_UNAVAILABLE,
)
if type(data) is lnurl.LnurlChannelResponse:
return jsonify({"domain": domain, "kind": "channel", "message": "unsupported"}), HTTPStatus.BAD_REQUEST
params.update(**data.dict())
if type(data) is lnurl.LnurlWithdrawResponse:
params.update(kind="withdraw")
params.update(fixed=data.min_withdrawable == data.max_withdrawable)
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data.callback)
qs: Dict = parse_qs(parsed_callback.query)
qs["k1"] = data.k1
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
params.update(callback=urlunparse(parsed_callback))
if type(data) is lnurl.LnurlPayResponse:
params.update(kind="pay")
params.update(fixed=data.min_sendable == data.max_sendable)
params.update(description_hash=data.metadata.h)
params.update(description=data.metadata.text)
if data.metadata.images:
image = min(data.metadata.images, key=lambda image: len(image[1]))
data_uri = "data:" + image[0] + "," + image[1]
params.update(image=data_uri)
params.update(commentAllowed=jdata.get("commentAllowed", 0))
return jsonify(params)
@core_app.route("/api/v1/lnurlauth", methods=["POST"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"callback": {"type": "string", "required": True},
}
)
async def api_perform_lnurlauth():
err = await perform_lnurlauth(g.data["callback"])
if err:
return jsonify({"reason": err.reason}), HTTPStatus.SERVICE_UNAVAILABLE
return "", HTTPStatus.OK
+50 -16
View File
@@ -1,10 +1,14 @@
from flask import g, abort, redirect, request, render_template, send_from_directory, url_for
import trio # type: ignore
import httpx
from os import path
from http import HTTPStatus
from os import getenv, path
from quart import g, abort, redirect, request, render_template, send_from_directory, url_for
from lnurl import LnurlResponse, LnurlWithdrawResponse, decode as decode_lnurl # type: ignore
from lnbits.core import core_app
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.settings import SERVICE_FEE
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE
from lnbits.tasks import run_on_pseudo_request
from ..crud import (
create_account,
@@ -13,22 +17,23 @@ from ..crud import (
create_wallet,
delete_wallet,
)
from ..services import redeem_lnurl_withdraw
@core_app.route("/favicon.ico")
def favicon():
return send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
async def favicon():
return await send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
@core_app.route("/")
def home():
return render_template("core/index.html", lnurl=request.args.get("lightning", None))
async def home():
return await render_template("core/index.html", lnurl=request.args.get("lightning", None))
@core_app.route("/extensions")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def extensions():
async def extensions():
extension_to_enable = request.args.get("enable", type=str)
extension_to_disable = request.args.get("disable", type=str)
@@ -40,12 +45,12 @@ def extensions():
elif extension_to_disable:
update_user_extension(user_id=g.user.id, extension=extension_to_disable, active=0)
return render_template("core/extensions.html", user=get_user(g.user.id))
return await render_template("core/extensions.html", user=get_user(g.user.id))
@core_app.route("/wallet")
@validate_uuids(["usr", "wal"])
def wallet():
async def wallet():
user_id = request.args.get("usr", type=str)
wallet_id = request.args.get("wal", type=str)
wallet_name = request.args.get("nme", type=str)
@@ -61,10 +66,9 @@ def wallet():
user = get_user(create_account().id)
else:
user = get_user(user_id) or abort(HTTPStatus.NOT_FOUND, "User does not exist.")
allowed_users = getenv("LNBITS_ALLOWED_USERS", "all")
if allowed_users != "all" and user_id not in allowed_users.split(","):
abort(HTTPStatus.UNAUTHORIZED, f"User not authorized.")
if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS:
abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
if not wallet_id:
if user.wallets and not wallet_name:
@@ -74,16 +78,17 @@ def wallet():
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
if wallet_id not in user.wallet_ids:
wallet = user.get_wallet(wallet_id)
if not wallet:
abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
return render_template("core/wallet.html", user=user, wallet=user.get_wallet(wallet_id), service_fee=service_fee)
return await render_template("core/wallet.html", user=user, wallet=wallet, service_fee=service_fee)
@core_app.route("/deletewallet")
@validate_uuids(["usr", "wal"], required=True)
@check_user_exists()
def deletewallet():
async def deletewallet():
wallet_id = request.args.get("wal", type=str)
user_wallet_ids = g.user.wallet_ids
@@ -97,3 +102,32 @@ def deletewallet():
return redirect(url_for("core.wallet", usr=g.user.id, wal=user_wallet_ids[0]))
return redirect(url_for("core.home"))
@core_app.route("/lnurlwallet")
async def lnurlwallet():
async with httpx.AsyncClient() as client:
try:
lnurl = decode_lnurl(request.args.get("lightning"))
r = await client.get(str(lnurl))
withdraw_res = LnurlResponse.from_dict(r.json())
if not withdraw_res.ok:
return f"Could not process lnurl-withdraw: {withdraw_res.error_msg}", HTTPStatus.BAD_REQUEST
if not isinstance(withdraw_res, LnurlWithdrawResponse):
return f"Expected an lnurl-withdraw code, got {withdraw_res.tag}", HTTPStatus.BAD_REQUEST
except Exception as exc:
return f"Could not process lnurl-withdraw: {exc}", HTTPStatus.INTERNAL_SERVER_ERROR
account = create_account()
user = get_user(account.id)
wallet = create_wallet(user_id=user.id)
g.db.commit()
await run_on_pseudo_request(
redeem_lnurl_withdraw, wallet.id, withdraw_res, "LNbits initial funding: voucher redeem."
)
await trio.sleep(3)
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
-61
View File
@@ -1,61 +0,0 @@
import requests
from flask import abort, redirect, request, url_for
from http import HTTPStatus
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore
from lnurl.exceptions import LnurlException # type: ignore
from time import sleep
from lnbits.core import core_app
from lnbits.settings import WALLET
from ..crud import create_account, get_user, create_wallet, create_payment
@core_app.route("/lnurlwallet")
def lnurlwallet():
memo = "LNbits LNURL funding"
try:
withdraw_res = handle_lnurl(request.args.get("lightning"))
if not withdraw_res.ok:
abort(HTTPStatus.BAD_REQUEST, f"Could not process LNURL-withdraw: {withdraw_res.error_msg}")
if not isinstance(withdraw_res, LnurlWithdrawResponse):
abort(HTTPStatus.BAD_REQUEST, "Not a valid LNURL-withdraw.")
except LnurlException:
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process LNURL-withdraw.")
try:
ok, checking_id, payment_request, error_message = WALLET.create_invoice(withdraw_res.max_sats, memo)
except Exception as e:
ok, error_message = False, str(e)
if not ok:
abort(HTTPStatus.INTERNAL_SERVER_ERROR, error_message)
r = requests.get(
withdraw_res.callback.base,
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
)
if not r.ok:
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process LNURL-withdraw.")
for i in range(10):
invoice_status = WALLET.get_invoice_status(checking_id)
sleep(i)
if not invoice_status.paid:
continue
break
user = get_user(create_account().id)
wallet = create_wallet(user_id=user.id)
create_payment(
wallet_id=wallet.id,
checking_id=checking_id,
amount=withdraw_res.max_sats * 1000,
memo=memo,
pending=invoice_status.pending,
)
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
+30 -6
View File
@@ -10,27 +10,51 @@ class Database:
self.connection = sqlite3.connect(db_path)
self.connection.row_factory = sqlite3.Row
self.cursor = self.connection.cursor()
self.closed = False
def close(self):
self.__exit__(None, None, None)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.cursor.close()
self.connection.close()
if self.closed:
return
if exc_val:
self.connection.rollback()
self.cursor.close()
self.connection.close()
else:
self.connection.commit()
self.cursor.close()
self.connection.close()
self.closed = True
def commit(self):
self.connection.commit()
def rollback(self):
self.connection.rollback()
def fetchall(self, query: str, values: tuple = ()) -> list:
"""Given a query, return cursor.fetchall() rows."""
self.cursor.execute(query, values)
self.execute(query, values)
return self.cursor.fetchall()
def fetchone(self, query: str, values: tuple = ()):
self.cursor.execute(query, values)
self.execute(query, values)
return self.cursor.fetchone()
def execute(self, query: str, values: tuple = ()) -> None:
"""Given a query, cursor.execute() it."""
self.cursor.execute(query, values)
self.connection.commit()
try:
self.cursor.execute(query, values)
except sqlite3.Error as exc:
self.connection.rollback()
raise exc
def open_db(db_name: str = "database") -> Database:
+29 -19
View File
@@ -1,27 +1,31 @@
from cerberus import Validator # type: ignore
from flask import g, abort, jsonify, request
from quart import g, abort, jsonify, request
from functools import wraps
from http import HTTPStatus
from os import getenv
from typing import List, Union
from uuid import UUID
from lnbits.core.crud import get_user, get_wallet_for_key
from lnbits.settings import LNBITS_ALLOWED_USERS
def api_check_wallet_key(key_type: str = "invoice"):
def api_check_wallet_key(key_type: str = "invoice", accept_querystring=False):
def wrap(view):
@wraps(view)
def wrapped_view(**kwargs):
async def wrapped_view(**kwargs):
try:
g.wallet = get_wallet_for_key(request.headers["X-Api-Key"], key_type)
key_value = request.headers.get("X-Api-Key") or request.args["api-key"]
g.wallet = get_wallet_for_key(key_value, key_type)
except KeyError:
return jsonify({"message": "`X-Api-Key` header missing."}), HTTPStatus.BAD_REQUEST
return (
jsonify({"message": "`X-Api-Key` header missing."}),
HTTPStatus.BAD_REQUEST,
)
if not g.wallet:
return jsonify({"message": "Wrong keys."}), HTTPStatus.UNAUTHORIZED
return view(**kwargs)
return await view(**kwargs)
return wrapped_view
@@ -31,17 +35,24 @@ def api_check_wallet_key(key_type: str = "invoice"):
def api_validate_post_request(*, schema: dict):
def wrap(view):
@wraps(view)
def wrapped_view(**kwargs):
async def wrapped_view(**kwargs):
if "application/json" not in request.headers["Content-Type"]:
return jsonify({"message": "Content-Type must be `application/json`."}), HTTPStatus.BAD_REQUEST
return (
jsonify({"message": "Content-Type must be `application/json`."}),
HTTPStatus.BAD_REQUEST,
)
v = Validator(schema)
g.data = {key: (request.json[key] if key in request.json else None) for key in schema.keys()}
data = await request.get_json()
g.data = {key: data[key] for key in schema.keys() if key in data}
if not v.validate(g.data):
return jsonify({"message": f"Errors in request data: {v.errors}"}), HTTPStatus.BAD_REQUEST
return (
jsonify({"message": f"Errors in request data: {v.errors}"}),
HTTPStatus.BAD_REQUEST,
)
return view(**kwargs)
return await view(**kwargs)
return wrapped_view
@@ -51,14 +62,13 @@ def api_validate_post_request(*, schema: dict):
def check_user_exists(param: str = "usr"):
def wrap(view):
@wraps(view)
def wrapped_view(**kwargs):
async def wrapped_view(**kwargs):
g.user = get_user(request.args.get(param, type=str)) or abort(HTTPStatus.NOT_FOUND, "User does not exist.")
allowed_users = getenv("LNBITS_ALLOWED_USERS", "all")
if allowed_users != "all" and g.user.id not in allowed_users.split(","):
abort(HTTPStatus.UNAUTHORIZED, f"User not authorized.")
if LNBITS_ALLOWED_USERS and g.user.id not in LNBITS_ALLOWED_USERS:
abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
return view(**kwargs)
return await view(**kwargs)
return wrapped_view
@@ -68,7 +78,7 @@ def check_user_exists(param: str = "usr"):
def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4):
def wrap(view):
@wraps(view)
def wrapped_view(**kwargs):
async def wrapped_view(**kwargs):
query_params = {param: request.args.get(param, type=str) for param in params}
for param, value in query_params.items():
@@ -81,7 +91,7 @@ def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = Fals
except ValueError:
abort(HTTPStatus.BAD_REQUEST, f"`{param}` is not a valid UUID.")
return view(**kwargs)
return await view(**kwargs)
return wrapped_view
+1 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint
from quart import Blueprint
amilk_ext: Blueprint = Blueprint("amilk", __name__, static_folder="static", template_folder="templates")
+1 -1
View File
@@ -9,7 +9,7 @@ from .models import AMilk
def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk:
with open_ext_db("amilk") as db:
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute(
"""
INSERT INTO amilks (id, wallet, lnurl, atime, amount)
+4 -10
View File
@@ -1,11 +1,9 @@
from lnbits.db import open_ext_db
def m001_initial(db):
"""
Initial amilks table.
"""
db.execute("""
db.execute(
"""
CREATE TABLE IF NOT EXISTS amilks (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
@@ -13,9 +11,5 @@ def m001_initial(db):
atime INTEGER NOT NULL,
amount INTEGER NOT NULL
);
""")
def migrate():
with open_ext_db("amilk") as db:
m001_initial(db)
"""
)
@@ -68,9 +68,7 @@
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "amilk/_api_docs.html" %}
</q-list>
<q-list> {% include "amilk/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
+5 -6
View File
@@ -1,4 +1,4 @@
from flask import g, abort, render_template
from quart import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
@@ -10,12 +10,11 @@ from .crud import get_amilk
@amilk_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def index():
return render_template("amilk/index.html", user=g.user)
async def index():
return await render_template("amilk/index.html", user=g.user)
@amilk_ext.route("/<amilk_id>")
def wall(amilk_id):
async def wall(amilk_id):
amilk = get_amilk(amilk_id) or abort(HTTPStatus.NOT_FOUND, "AMilk does not exist.")
return render_template("amilk/wall.html", amilk=amilk)
return await render_template("amilk/wall.html", amilk=amilk)
+31 -37
View File
@@ -1,23 +1,21 @@
from flask import g, jsonify, request
import httpx
from quart import g, jsonify, request, abort
from http import HTTPStatus
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.extensions.amilk import amilk_ext
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
from lnbits.core.services import create_invoice
from flask import abort, redirect, request, url_for
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl
from lnurl.exceptions import LnurlException
from time import sleep
import requests
from lnbits.settings import WALLET
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.core.services import create_invoice, check_invoice_status
from lnbits.extensions.amilk import amilk_ext
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
@amilk_ext.route("/api/v1/amilk", methods=["GET"])
@api_check_wallet_key("invoice")
def api_amilks():
async def api_amilks():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
@@ -27,7 +25,7 @@ def api_amilks():
@amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"])
def api_amilkit(amilk_id):
async def api_amilkit(amilk_id):
milk = get_amilk(amilk_id)
memo = milk.id
@@ -35,44 +33,40 @@ def api_amilkit(amilk_id):
withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse)
except LnurlException:
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
print(withdraw_res.max_sats)
payment_hash, payment_request = create_invoice(
wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo, extra={"tag": "amilk"}
)
try:
checking_id, payment_request = create_invoice(wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo)
#print(payment_request)
except Exception as e:
error_message = False, str(e)
r = requests.get(
r = httpx.get(
withdraw_res.callback.base,
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
)
if not r.ok:
if r.is_error:
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
for i in range(10):
invoice_status = WALLET.get_invoice_status(checking_id)
sleep(i)
if not invoice_status.paid:
continue
invoice_status = check_invoice_status(milk.wallet, payment_hash)
if invoice_status.paid:
return jsonify({"paid": True}), HTTPStatus.OK
else:
return jsonify({"paid": False}), HTTPStatus.OK
break
continue
return jsonify({"paid": True}), HTTPStatus.OK
return jsonify({"paid": False}), HTTPStatus.OK
@amilk_ext.route("/api/v1/amilk", methods=["POST"])
@api_check_wallet_key("invoice")
@api_validate_post_request(schema={
"lnurl": {"type": "string", "empty": False, "required": True},
"atime": {"type": "integer", "min": 0, "required": True},
"amount": {"type": "integer", "min": 0, "required": True},
})
def api_amilk_create():
@api_validate_post_request(
schema={
"lnurl": {"type": "string", "empty": False, "required": True},
"atime": {"type": "integer", "min": 0, "required": True},
"amount": {"type": "integer", "min": 0, "required": True},
}
)
async def api_amilk_create():
amilk = create_amilk(wallet_id=g.wallet.id, lnurl=g.data["lnurl"], atime=g.data["atime"], amount=g.data["amount"])
return jsonify(amilk._asdict()), HTTPStatus.CREATED
@@ -80,7 +74,7 @@ def api_amilk_create():
@amilk_ext.route("/api/v1/amilk/<amilk_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
def api_amilk_delete(amilk_id):
async def api_amilk_delete(amilk_id):
amilk = get_amilk(amilk_id)
if not amilk:
+1 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint
from quart import Blueprint
diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates")
+96 -28
View File
@@ -1,24 +1,30 @@
from base64 import urlsafe_b64encode
from uuid import uuid4
from typing import List, Optional, Union
import requests
import httpx
from lnbits.db import open_ext_db
from lnbits.settings import WALLET
from .models import Products, Orders, Indexers
import re
regex = re.compile(
r'^(?:http|ftp)s?://' # http:// or https://
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'
r'localhost|'
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
r'(?::\d+)?'
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
r"^(?:http|ftp)s?://" # http:// or https://
r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|"
r"localhost|"
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
r"(?::\d+)?"
r"(?:/?|[/?]\S+)$",
re.IGNORECASE,
)
###Products
def create_diagonalleys_product(*, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int) -> Products:
def create_diagonalleys_product(
*, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int
) -> Products:
with open_ext_db("diagonalley") as db:
product_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute(
"""
INSERT INTO products (id, wallet, product, categories, description, image, price, quantity)
@@ -63,19 +69,39 @@ def delete_diagonalleys_product(product_id: str) -> None:
db.execute("DELETE FROM products WHERE id = ?", (product_id,))
###Indexers
def create_diagonalleys_indexer(wallet_id: str, shopname: str, indexeraddress: str, shippingzone1: str, shippingzone2: str, zone1cost: int, zone2cost: int, email: str) -> Indexers:
def create_diagonalleys_indexer(
wallet_id: str,
shopname: str,
indexeraddress: str,
shippingzone1: str,
shippingzone2: str,
zone1cost: int,
zone2cost: int,
email: str,
) -> Indexers:
with open_ext_db("diagonalley") as db:
indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute(
"""
INSERT INTO indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(indexer_id, wallet_id, shopname, indexeraddress, False, 0, shippingzone1, shippingzone2, zone1cost, zone2cost, email),
(
indexer_id,
wallet_id,
shopname,
indexeraddress,
False,
0,
shippingzone1,
shippingzone2,
zone1cost,
zone2cost,
email,
),
)
return get_diagonalleys_indexer(indexer_id)
@@ -94,17 +120,29 @@ def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
with open_ext_db("diagonalley") as db:
roww = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
try:
x = requests.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
x = httpx.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
if x.status_code == 200:
print(x)
print("poo")
with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (True, indexer_id,))
db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
True,
indexer_id,
),
)
else:
with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (False, indexer_id,))
db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
False,
indexer_id,
),
)
except:
print("An exception occurred")
print("An exception occurred")
with open_ext_db("diagonalley") as db:
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
return Indexers(**row) if row else None
@@ -120,15 +158,27 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
for r in rows:
try:
x = requests.get(r["indexeraddress"] + "/" + r["ratingkey"])
x = httpx.get(r["indexeraddress"] + "/" + r["ratingkey"])
if x.status_code == 200:
with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (True, r["id"],))
db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
True,
r["id"],
),
)
else:
with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (False, r["id"],))
db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
False,
r["id"],
),
)
except:
print("An exception occurred")
print("An exception occurred")
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,))
@@ -140,12 +190,24 @@ def delete_diagonalleys_indexer(indexer_id: str) -> None:
db.execute("DELETE FROM indexers WHERE id = ?", (indexer_id,))
###Orders
def create_diagonalleys_order(*, productid: str, wallet: str, product: str, quantity: int, shippingzone: str, address: str, email: str, invoiceid: str, paid: bool, shipped: bool) -> Indexers:
def create_diagonalleys_order(
*,
productid: str,
wallet: str,
product: str,
quantity: int,
shippingzone: str,
address: str,
email: str,
invoiceid: str,
paid: bool,
shipped: bool,
) -> Indexers:
with open_ext_db("diagonalley") as db:
order_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
order_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute(
"""
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
@@ -153,7 +215,7 @@ def create_diagonalleys_order(*, productid: str, wallet: str, product: str, qua
""",
(order_id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, False, False),
)
return get_diagonalleys_order(order_id)
@@ -175,7 +237,13 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
PAID = WALLET.get_invoice_status(r["invoiceid"]).paid
if PAID:
with open_ext_db("diagonalley") as db:
db.execute("UPDATE orders SET paid = ? WHERE id = ?", (True, r["id"],))
db.execute(
"UPDATE orders SET paid = ? WHERE id = ?",
(
True,
r["id"],
),
)
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
return [Orders(**row) for row in rows]
+12 -16
View File
@@ -1,11 +1,9 @@
from lnbits.db import open_ext_db
def m001_initial(db):
"""
Initial products table.
"""
db.execute("""
db.execute(
"""
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
@@ -16,13 +14,14 @@ def m001_initial(db):
price INTEGER NOT NULL,
quantity INTEGER NOT NULL
);
""")
"""
)
"""
Initial indexers table.
"""
db.execute("""
db.execute(
"""
CREATE TABLE IF NOT EXISTS indexers (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
@@ -36,13 +35,14 @@ def m001_initial(db):
zone2cost INTEGER NOT NULL,
email TEXT NOT NULL
);
""")
"""
)
"""
Initial orders table.
"""
db.execute("""
db.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
productid TEXT NOT NULL,
@@ -56,9 +56,5 @@ def m001_initial(db):
paid BOOLEAN NOT NULL,
shipped BOOLEAN NOT NULL
);
""")
def migrate():
with open_ext_db("diagonalley") as db:
m001_initial(db)
"""
)
+3 -2
View File
@@ -1,5 +1,6 @@
from typing import NamedTuple
class Indexers(NamedTuple):
id: str
wallet: str
@@ -13,6 +14,7 @@ class Indexers(NamedTuple):
zone2cost: int
email: str
class Products(NamedTuple):
id: str
wallet: str
@@ -23,6 +25,7 @@ class Products(NamedTuple):
price: int
quantity: int
class Orders(NamedTuple):
id: str
productid: str
@@ -35,5 +38,3 @@ class Orders(NamedTuple):
invoiceid: str
paid: bool
shipped: bool
@@ -59,9 +59,7 @@
:href="props.row.wallet"
target="_blank"
></q-btn>
<q-tooltip>
Link to pass to stall indexer
</q-tooltip>
<q-tooltip> Link to pass to stall indexer </q-tooltip>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
@@ -194,9 +192,7 @@
icon="add_shopping_cart"
color="green"
>
<q-tooltip>
Product shipped?
</q-tooltip>
<q-tooltip> Product shipped? </q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
@@ -224,15 +220,13 @@
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "diagonalley/_api_docs.html" %}
</q-list>
<q-list> {% include "diagonalley/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="productDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px;">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendProductFormData" class="q-gutter-md">
<q-select
filled
@@ -314,7 +308,7 @@
</q-dialog>
<q-dialog v-model="indexerDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px;">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendIndexerFormData" class="q-gutter-md">
<q-select
filled
+3 -7
View File
@@ -1,15 +1,11 @@
import json
from flask import g, abort, render_template, jsonify
from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.diagonalley import diagonalley_ext
from lnbits.db import open_ext_db
@diagonalley_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def index():
return render_template("diagonalley/index.html", user=g.user)
async def index():
return await render_template("diagonalley/index.html", user=g.user)
+109 -55
View File
@@ -1,21 +1,36 @@
from flask import g, jsonify, request
from quart import g, jsonify, request
from http import HTTPStatus
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.extensions.diagonalley import diagonalley_ext
from .crud import create_diagonalleys_product,get_diagonalleys_product,get_diagonalleys_products,delete_diagonalleys_product,create_diagonalleys_indexer,update_diagonalleys_indexer,get_diagonalleys_indexer,get_diagonalleys_indexers,delete_diagonalleys_indexer,create_diagonalleys_order,get_diagonalleys_order,get_diagonalleys_orders,delete_diagonalleys_order
from .crud import (
create_diagonalleys_product,
get_diagonalleys_product,
get_diagonalleys_products,
delete_diagonalleys_product,
create_diagonalleys_indexer,
update_diagonalleys_indexer,
get_diagonalleys_indexer,
get_diagonalleys_indexers,
delete_diagonalleys_indexer,
create_diagonalleys_order,
get_diagonalleys_order,
get_diagonalleys_orders,
update_diagonalleys_product,
)
from lnbits.core.services import create_invoice
from base64 import urlsafe_b64encode
from uuid import uuid4
from lnbits.db import open_ext_db
###Products
### Products
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_products():
async def api_diagonalley_products():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
@@ -27,15 +42,17 @@ def api_diagonalley_products():
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"])
@diagonalley_ext.route("/api/v1/diagonalley/products<product_id>", methods=["PUT"])
@api_check_wallet_key(key_type="invoice")
@api_validate_post_request(schema={
"product": {"type": "string", "empty": False, "required": True},
"categories": {"type": "string", "empty": False, "required": True},
"description": {"type": "string", "empty": False, "required": True},
"image": {"type": "string", "empty": False, "required": True},
"price": {"type": "integer", "min": 0, "required": True},
"quantity": {"type": "integer", "min": 0, "required": True}
})
def api_diagonalley_product_create(product_id=None):
@api_validate_post_request(
schema={
"product": {"type": "string", "empty": False, "required": True},
"categories": {"type": "string", "empty": False, "required": True},
"description": {"type": "string", "empty": False, "required": True},
"image": {"type": "string", "empty": False, "required": True},
"price": {"type": "integer", "min": 0, "required": True},
"quantity": {"type": "integer", "min": 0, "required": True},
}
)
async def api_diagonalley_product_create(product_id=None):
if product_id:
product = get_diagonalleys_indexer(product_id)
@@ -53,10 +70,9 @@ def api_diagonalley_product_create(product_id=None):
return jsonify(product._asdict()), HTTPStatus.OK if product_id else HTTPStatus.CREATED
@diagonalley_ext.route("/api/v1/diagonalley/products/<product_id>", methods=["DELETE"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_products_delete(product_id):
async def api_diagonalley_products_delete(product_id):
product = get_diagonalleys_product(product_id)
if not product:
@@ -70,12 +86,12 @@ def api_diagonalley_products_delete(product_id):
return "", HTTPStatus.NO_CONTENT
###Indexers
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_indexers():
async def api_diagonalley_indexers():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
@@ -87,16 +103,18 @@ def api_diagonalley_indexers():
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"])
@diagonalley_ext.route("/api/v1/diagonalley/indexers<indexer_id>", methods=["PUT"])
@api_check_wallet_key(key_type="invoice")
@api_validate_post_request(schema={
"shopname": {"type": "string", "empty": False, "required": True},
"indexeraddress": {"type": "string", "empty": False, "required": True},
"shippingzone1": {"type": "string", "empty": False, "required": True},
"shippingzone2": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"zone1cost": {"type": "integer", "min": 0, "required": True},
"zone2cost": {"type": "integer", "min": 0, "required": True}
})
def api_diagonalley_indexer_create(indexer_id=None):
@api_validate_post_request(
schema={
"shopname": {"type": "string", "empty": False, "required": True},
"indexeraddress": {"type": "string", "empty": False, "required": True},
"shippingzone1": {"type": "string", "empty": False, "required": True},
"shippingzone2": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"zone1cost": {"type": "integer", "min": 0, "required": True},
"zone2cost": {"type": "integer", "min": 0, "required": True},
}
)
async def api_diagonalley_indexer_create(indexer_id=None):
if indexer_id:
indexer = get_diagonalleys_indexer(indexer_id)
@@ -116,7 +134,7 @@ def api_diagonalley_indexer_create(indexer_id=None):
@diagonalley_ext.route("/api/v1/diagonalley/indexers/<indexer_id>", methods=["DELETE"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_indexer_delete(indexer_id):
async def api_diagonalley_indexer_delete(indexer_id):
indexer = get_diagonalleys_indexer(indexer_id)
if not indexer:
@@ -132,9 +150,10 @@ def api_diagonalley_indexer_delete(indexer_id):
###Orders
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_orders():
async def api_diagonalley_orders():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
@@ -145,21 +164,23 @@ def api_diagonalley_orders():
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"])
@api_check_wallet_key(key_type="invoice")
@api_validate_post_request(schema={
"id": {"type": "string", "empty": False, "required": True},
"address": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"quantity": {"type": "integer", "empty": False, "required": True},
"shippingzone": {"type": "integer", "empty": False, "required": True},
})
def api_diagonalley_order_create():
@api_validate_post_request(
schema={
"id": {"type": "string", "empty": False, "required": True},
"address": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"quantity": {"type": "integer", "empty": False, "required": True},
"shippingzone": {"type": "integer", "empty": False, "required": True},
}
)
async def api_diagonalley_order_create():
order = create_diagonalleys_order(wallet_id=g.wallet.id, **g.data)
return jsonify(order._asdict()), HTTPStatus.CREATED
@diagonalley_ext.route("/api/v1/diagonalley/orders/<order_id>", methods=["DELETE"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_order_delete(order_id):
async def api_diagonalley_order_delete(order_id):
order = get_diagonalleys_order(order_id)
if not order:
@@ -175,17 +196,29 @@ def api_diagonalley_order_delete(order_id):
@diagonalley_ext.route("/api/v1/diagonalley/orders/paid/<order_id>", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalleys_order_paid(order_id):
async def api_diagonalleys_order_paid(order_id):
with open_ext_db("diagonalley") as db:
db.execute("UPDATE orders SET paid = ? WHERE id = ?", (True, order_id,))
db.execute(
"UPDATE orders SET paid = ? WHERE id = ?",
(
True,
order_id,
),
)
return "", HTTPStatus.OK
@diagonalley_ext.route("/api/v1/diagonalley/orders/shipped/<order_id>", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalleys_order_shipped(order_id):
async def api_diagonalleys_order_shipped(order_id):
with open_ext_db("diagonalley") as db:
db.execute("UPDATE orders SET shipped = ? WHERE id = ?", (True, order_id,))
db.execute(
"UPDATE orders SET shipped = ? WHERE id = ?",
(
True,
order_id,
),
)
order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
return jsonify([order._asdict() for order in get_diagonalleys_orders(order["wallet"])]), HTTPStatus.OK
@@ -193,8 +226,9 @@ def api_diagonalleys_order_shipped(order_id):
###List products based on indexer id
@diagonalley_ext.route("/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"])
def api_diagonalleys_stall_products(indexer_id):
async def api_diagonalleys_stall_products(indexer_id):
with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
print(rows[1])
@@ -207,26 +241,32 @@ def api_diagonalleys_stall_products(indexer_id):
return jsonify([products._asdict() for products in get_diagonalleys_products(rows[1])]), HTTPStatus.OK
###Check a product has been shipped
@diagonalley_ext.route("/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"])
def api_diagonalleys_stall_checkshipped(checking_id):
async def api_diagonalleys_stall_checkshipped(checking_id):
with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,))
return jsonify({"shipped": rows["shipped"]}), HTTPStatus.OK
###Place order
@diagonalley_ext.route("/api/v1/diagonalley/stall/order/<indexer_id>", methods=["POST"])
@api_validate_post_request(schema={
"id": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"address": {"type": "string", "empty": False, "required": True},
"quantity": {"type": "integer", "empty": False, "required": True},
"shippingzone": {"type": "integer", "empty": False, "required": True},
})
def api_diagonalley_stall_order(indexer_id):
@api_validate_post_request(
schema={
"id": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"address": {"type": "string", "empty": False, "required": True},
"quantity": {"type": "integer", "empty": False, "required": True},
"shippingzone": {"type": "integer", "empty": False, "required": True},
}
)
async def api_diagonalley_stall_order(indexer_id):
product = get_diagonalleys_product(g.data["id"])
shipping = get_diagonalleys_indexer(indexer_id)
@@ -235,14 +275,28 @@ def api_diagonalley_stall_order(indexer_id):
else:
shippingcost = shipping.zone2cost
checking_id, payment_request = create_invoice(wallet_id=product.wallet, amount=shippingcost + (g.data["quantity"] * product.price), memo=g.data["id"])
selling_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
checking_id, payment_request = create_invoice(
wallet_id=product.wallet, amount=shippingcost + (g.data["quantity"] * product.price), memo=g.data["id"]
)
selling_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
with open_ext_db("diagonalley") as db:
db.execute(
"""
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(selling_id ,g.data["id"] , product.wallet, product.product, g.data["quantity"], g.data["shippingzone"], g.data["address"], g.data["email"], checking_id, False, False),
(
selling_id,
g.data["id"],
product.wallet,
product.product,
g.data["quantity"],
g.data["shippingzone"],
g.data["address"],
g.data["email"],
checking_id,
False,
False,
),
)
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK
+1 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint
from quart import Blueprint
events_ext: Blueprint = Blueprint("events", __name__, static_folder="static", template_folder="templates")
+42 -17
View File
@@ -9,32 +9,33 @@ from .models import Tickets, Events
#######TICKETS########
def create_ticket(checking_id: str, wallet: str, event: str, name: str, email: str) -> Tickets:
def create_ticket(payment_hash: str, wallet: str, event: str, name: str, email: str) -> Tickets:
with open_ext_db("events") as db:
db.execute(
"""
INSERT INTO ticket (id, wallet, event, name, email, registered, paid)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(checking_id, wallet, event, name, email, False, False),
(payment_hash, wallet, event, name, email, False, False),
)
return get_ticket(checking_id)
return get_ticket(payment_hash)
def update_ticket(paid: bool, checking_id: str) -> Tickets:
def update_ticket(paid: bool, payment_hash: str) -> Tickets:
with open_ext_db("events") as db:
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (checking_id,))
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
if row[6] == True:
return get_ticket(checking_id)
return get_ticket(payment_hash)
db.execute(
"""
UPDATE ticket
SET paid = ?
WHERE id = ?
""",
(paid, checking_id),
(paid, payment_hash),
)
eventdata = get_event(row[2])
sold = eventdata.sold + 1
amount_tickets = eventdata.amount_tickets - 1
@@ -46,12 +47,12 @@ def update_ticket(paid: bool, checking_id: str) -> Tickets:
""",
(sold, amount_tickets, row[2]),
)
return get_ticket(checking_id)
return get_ticket(payment_hash)
def get_ticket(checking_id: str) -> Optional[Tickets]:
def get_ticket(payment_hash: str) -> Optional[Tickets]:
with open_ext_db("events") as db:
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (checking_id,))
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
return Tickets(**row) if row else None
@@ -67,16 +68,25 @@ def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
return [Tickets(**row) for row in rows]
def delete_ticket(checking_id: str) -> None:
def delete_ticket(payment_hash: str) -> None:
with open_ext_db("events") as db:
db.execute("DELETE FROM ticket WHERE id = ?", (checking_id,))
db.execute("DELETE FROM ticket WHERE id = ?", (payment_hash,))
########EVENTS#########
def create_event(*, wallet: str, name: str, info: str, closing_date: str, event_start_date: str, event_end_date: str, amount_tickets: int, price_per_ticket: int) -> Events:
def create_event(
*,
wallet: str,
name: str,
info: str,
closing_date: str,
event_start_date: str,
event_end_date: str,
amount_tickets: int,
price_per_ticket: int,
) -> Events:
with open_ext_db("events") as db:
event_id = urlsafe_short_hash()
db.execute(
@@ -84,12 +94,24 @@ def create_event(*, wallet: str, name: str, info: str, closing_date: str, event_
INSERT INTO events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(event_id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, 0),
(
event_id,
wallet,
name,
info,
closing_date,
event_start_date,
event_end_date,
amount_tickets,
price_per_ticket,
0,
),
)
print(event_id)
return get_event(event_id)
def update_event(event_id: str, **kwargs) -> Events:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("events") as db:
@@ -122,8 +144,10 @@ def delete_event(event_id: str) -> None:
with open_ext_db("events") as db:
db.execute("DELETE FROM events WHERE id = ?", (event_id,))
########EVENTTICKETS#########
def get_event_tickets(event_id: str, wallet_id: str) -> Tickets:
with open_ext_db("events") as db:
@@ -132,6 +156,7 @@ def get_event_tickets(event_id: str, wallet_id: str) -> Tickets:
return [Tickets(**row) for row in rows]
def reg_ticket(ticket_id: str) -> Tickets:
with open_ext_db("events") as db:
db.execute("UPDATE ticket SET registered = ? WHERE id = ?", (True, ticket_id))
@@ -139,4 +164,4 @@ def reg_ticket(ticket_id: str) -> Tickets:
print(ticket[1])
rows = db.fetchall("SELECT * FROM ticket WHERE event = ?", (ticket[1],))
return [Tickets(**row) for row in rows]
return [Tickets(**row) for row in rows]
+7 -15
View File
@@ -1,5 +1,3 @@
from lnbits.db import open_ext_db
def m001_initial(db):
db.execute(
@@ -51,7 +49,7 @@ def m002_changed(db):
);
"""
)
for row in [list(row) for row in db.fetchall("SELECT * FROM tickets")]:
usescsv = ""
@@ -60,7 +58,7 @@ def m002_changed(db):
usescsv += "," + str(i + 1)
else:
usescsv += "," + str(1)
usescsv = usescsv[1:]
usescsv = usescsv[1:]
db.execute(
"""
INSERT INTO ticket (
@@ -75,19 +73,13 @@ def m002_changed(db):
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
row[0],
row[1],
row[2],
row[0],
row[1],
row[2],
row[3],
row[4],
row[5],
row[4],
row[5],
True,
),
)
db.execute("DROP TABLE tickets")
def migrate():
with open_ext_db("events") as db:
m001_initial(db)
m002_changed(db)
+2 -1
View File
@@ -14,6 +14,7 @@ class Events(NamedTuple):
sold: int
time: int
class Tickets(NamedTuple):
id: str
wallet: str
@@ -22,4 +23,4 @@ class Tickets(NamedTuple):
email: str
registered: bool
paid: bool
time: int
time: int
@@ -23,7 +23,6 @@
label="Your email "
></q-input>
<p>{% raw %}{{amountWords}}{% endraw %}</p>
<div class="row q-mt-lg">
<q-btn
unelevated
@@ -82,7 +81,6 @@
</div>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
console.log('{{ form_costpword }}')
Vue.component(VueQrcode.name, VueQrcode)
@@ -144,7 +142,7 @@
)
.then(function (response) {
self.paymentReq = response.data.payment_request
self.paymentCheck = response.data.checking_id
self.paymentCheck = response.data.payment_hash
dismissMsg = self.$q.notify({
timeout: 0,
@@ -9,7 +9,7 @@
<q-icon
name="warning"
class="text-grey"
style="font-size: 20rem;"
style="font-size: 20rem"
></q-icon>
<h5 class="q-my-none">{{ event_error }}</h5>
@@ -168,9 +168,7 @@
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "events/_api_docs.html" %}
</q-list>
<q-list> {% include "events/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
@@ -78,24 +78,7 @@
</q-card>
</q-dialog>
</div>
{% endblock %} {% block styles %}
<link
rel="stylesheet"
type="text/css"
href="{{ url_for('static', filename='vendor/vue-qrcode-reader@2.2.0/vue-qrcode-reader.min.css') }}"
/>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
{% assets filters='rjsmin', output='__bundle__/core/chart.js',
'vendor/moment@2.25.1/moment.min.js', 'vendor/chart.js@2.9.3/chart.min.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %} {% assets filters='rjsmin', output='__bundle__/core/wallet.js',
'vendor/bolt11/utils.js', 'vendor/bolt11/decoder.js',
'vendor/vue-qrcode-reader@2.2.0/vue-qrcode-reader.min.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
@@ -157,7 +140,7 @@
message: 'Registered!'
})
setTimeout(function () {
window.location.reload()
window.location.reload()
}, 2000)
})
.catch(function (error) {
@@ -27,7 +27,6 @@
</div>
</div>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
+26 -16
View File
@@ -1,4 +1,4 @@
from flask import g, abort, render_template
from quart import g, abort, render_template
from datetime import date, datetime
from lnbits.decorators import check_user_exists, validate_uuids
@@ -11,35 +11,45 @@ from .crud import get_ticket, get_event
@events_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def index():
return render_template("events/index.html", user=g.user)
async def index():
return await render_template("events/index.html", user=g.user)
@events_ext.route("/<event_id>")
def display(event_id):
async def display(event_id):
event = get_event(event_id) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
if event.amount_tickets < 1:
return render_template("events/error.html", event_name=event.name, event_error="Sorry, tickets are sold out :(")
datetime_object = datetime.strptime(event.closing_date, '%Y-%m-%d').date()
return await render_template(
"events/error.html", event_name=event.name, event_error="Sorry, tickets are sold out :("
)
datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date()
if date.today() > datetime_object:
return render_template("events/error.html", event_name=event.name, event_error="Sorry, ticket closing date has passed :(")
return await render_template(
"events/error.html", event_name=event.name, event_error="Sorry, ticket closing date has passed :("
)
return render_template("events/display.html", event_id=event_id, event_name=event.name, event_info=event.info, event_price=event.price_per_ticket)
return await render_template(
"events/display.html",
event_id=event_id,
event_name=event.name,
event_info=event.info,
event_price=event.price_per_ticket,
)
@events_ext.route("/ticket/<ticket_id>")
def ticket(ticket_id):
async def ticket(ticket_id):
ticket = get_ticket(ticket_id) or abort(HTTPStatus.NOT_FOUND, "Ticket does not exist.")
event = get_event(ticket.event) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
return render_template("events/ticket.html", ticket_id=ticket_id, ticket_name=event.name, ticket_info=event.info)
return await render_template(
"events/ticket.html", ticket_id=ticket_id, ticket_name=event.name, ticket_info=event.info
)
@events_ext.route("/register/<event_id>")
def register(event_id):
async def register(event_id):
event = get_event(event_id) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
return render_template("events/register.html", event_id=event_id, event_name=event.name, wallet_id=event.wallet)
return await render_template(
"events/register.html", event_id=event_id, event_name=event.name, wallet_id=event.wallet
)
+50 -35
View File
@@ -1,13 +1,25 @@
from flask import g, jsonify, request
from quart import g, jsonify, request
from http import HTTPStatus
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import create_invoice
from lnbits.core.services import create_invoice, check_invoice_status
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.settings import WALLET
from lnbits.extensions.events import events_ext
from .crud import create_ticket, update_ticket, get_ticket, get_tickets, delete_ticket, create_event, update_event, get_event, get_events, delete_event, get_event_tickets, reg_ticket
from .crud import (
create_ticket,
update_ticket,
get_ticket,
get_tickets,
delete_ticket,
create_event,
update_event,
get_event,
get_events,
delete_event,
get_event_tickets,
reg_ticket,
)
#########Events##########
@@ -15,7 +27,7 @@ from .crud import create_ticket, update_ticket, get_ticket, get_tickets, delete_
@events_ext.route("/api/v1/events", methods=["GET"])
@api_check_wallet_key("invoice")
def api_events():
async def api_events():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
@@ -36,10 +48,10 @@ def api_events():
"event_start_date": {"type": "string", "empty": False, "required": True},
"event_end_date": {"type": "string", "empty": False, "required": True},
"amount_tickets": {"type": "integer", "min": 0, "required": True},
"price_per_ticket": {"type": "integer", "min": 0, "required": True}
"price_per_ticket": {"type": "integer", "min": 0, "required": True},
}
)
def api_event_create(event_id=None):
async def api_event_create(event_id=None):
if event_id:
event = get_event(event_id)
print(g.data)
@@ -59,7 +71,7 @@ def api_event_create(event_id=None):
@events_ext.route("/api/v1/events/<event_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
def api_form_delete(event_id):
async def api_form_delete(event_id):
event = get_event(event_id)
if not event:
@@ -75,9 +87,10 @@ def api_form_delete(event_id):
#########Tickets##########
@events_ext.route("/api/v1/tickets", methods=["GET"])
@api_check_wallet_key("invoice")
def api_tickets():
async def api_tickets():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
@@ -90,42 +103,41 @@ def api_tickets():
@api_validate_post_request(
schema={
"name": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True}
})
def api_ticket_make_ticket(event_id, sats):
"email": {"type": "string", "empty": False, "required": True},
}
)
async def api_ticket_make_ticket(event_id, sats):
event = get_event(event_id)
if not event:
return jsonify({"message": "LNTicket does not exist."}), HTTPStatus.NOT_FOUND
return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND
try:
checking_id, payment_request = create_invoice(
wallet_id=event.wallet, amount=int(sats), memo=f"#lnticket {event_id}"
payment_hash, payment_request = create_invoice(
wallet_id=event.wallet, amount=int(sats), memo=f"{event_id}", extra={"tag": "events"}
)
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
ticket = create_ticket(checking_id=checking_id, wallet=event.wallet, event=event_id, **g.data)
ticket = create_ticket(payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data)
if not ticket:
return jsonify({"message": "LNTicket could not be fetched."}), HTTPStatus.NOT_FOUND
return jsonify({"message": "Event could not be fetched."}), HTTPStatus.NOT_FOUND
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK
@events_ext.route("/api/v1/tickets/<checking_id>", methods=["GET"])
def api_ticket_send_ticket(checking_id):
theticket = get_ticket(checking_id)
@events_ext.route("/api/v1/tickets/<payment_hash>", methods=["GET"])
async def api_ticket_send_ticket(payment_hash):
ticket = get_ticket(payment_hash)
try:
is_paid = not WALLET.get_invoice_status(checking_id).pending
is_paid = not check_invoice_status(ticket.wallet, payment_hash).pending
except Exception:
return jsonify({"message": "Not paid."}), HTTPStatus.NOT_FOUND
if is_paid:
wallet = get_wallet(theticket.wallet)
payment = wallet.get_payment(checking_id)
wallet = get_wallet(ticket.wallet)
payment = wallet.get_payment(payment_hash)
payment.set_pending(False)
ticket = update_ticket(paid=True, checking_id=checking_id)
ticket = update_ticket(paid=True, payment_hash=payment_hash)
return jsonify({"paid": True, "ticket_id": ticket.id}), HTTPStatus.OK
@@ -134,7 +146,7 @@ def api_ticket_send_ticket(checking_id):
@events_ext.route("/api/v1/tickets/<ticket_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
def api_ticket_delete(ticket_id):
async def api_ticket_delete(ticket_id):
ticket = get_ticket(ticket_id)
if not ticket:
@@ -150,14 +162,19 @@ def api_ticket_delete(ticket_id):
#########EventTickets##########
@events_ext.route("/api/v1/eventtickets/<wallet_id>/<event_id>", methods=["GET"])
def api_event_tickets(wallet_id, event_id):
return jsonify([ticket._asdict() for ticket in get_event_tickets(wallet_id=wallet_id, event_id=event_id)]), HTTPStatus.OK
@events_ext.route("/api/v1/eventtickets/<wallet_id>/<event_id>", methods=["GET"])
async def api_event_tickets(wallet_id, event_id):
return (
jsonify([ticket._asdict() for ticket in get_event_tickets(wallet_id=wallet_id, event_id=event_id)]),
HTTPStatus.OK,
)
@events_ext.route("/api/v1/register/ticket/<ticket_id>", methods=["GET"])
def api_event_register_ticket(ticket_id):
async def api_event_register_ticket(ticket_id):
ticket = get_ticket(ticket_id)
if not ticket:
@@ -166,6 +183,4 @@ def api_event_register_ticket(ticket_id):
if ticket.registered == True:
return jsonify({"message": "Ticket already registered"}), HTTPStatus.FORBIDDEN
return jsonify([ticket._asdict() for ticket in reg_ticket(ticket_id)]), HTTPStatus.OK
+1 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint
from quart import Blueprint
example_ext: Blueprint = Blueprint("example", __name__, static_folder="static", template_folder="templates")
-5
View File
@@ -1,5 +0,0 @@
from lnbits.db import open_ext_db
def migrate():
print("pending")
+3 -3
View File
@@ -1,4 +1,4 @@
from flask import g, render_template
from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.example import example_ext
@@ -7,5 +7,5 @@ from lnbits.extensions.example import example_ext
@example_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def index():
return render_template("example/index.html", user=g.user)
async def index():
return await render_template("example/index.html", user=g.user)
+7 -4
View File
@@ -3,9 +3,11 @@
# add your dependencies here
# import json
# import requests
# import httpx
# (use httpx just like requests, except instead of response.ok there's only the
# response.is_error that is its inverse)
from flask import jsonify
from quart import jsonify
from http import HTTPStatus
from lnbits.extensions.example import example_ext
@@ -13,8 +15,9 @@ from lnbits.extensions.example import example_ext
# add your endpoints here
@example_ext.route("/api/v1/tools", methods=["GET"])
def api_example():
async def api_example():
"""Try to add descriptions for others."""
tools = [
{
@@ -31,7 +34,7 @@ def api_example():
"name": "Quasar Framework",
"url": "https://quasar.dev/",
"language": "JavaScript",
}
},
]
return jsonify(tools), HTTPStatus.OK
+6
View File
@@ -0,0 +1,6 @@
<h1>lndhub Extension</h1>
<h2>*connect to your lnbits wallet from BlueWallet or Zeus*</h2>
Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/.
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club.
+8
View File
@@ -0,0 +1,8 @@
from quart import Blueprint
lndhub_ext: Blueprint = Blueprint("lndhub", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
from .views import * # noqa
+6
View File
@@ -0,0 +1,6 @@
{
"name": "LndHub",
"short_description": "Access lnbits from BlueWallet or Zeus.",
"icon": "navigation",
"contributors": ["fiatjaf"]
}
+25
View File
@@ -0,0 +1,25 @@
from base64 import b64decode
from quart import jsonify, g, request
from functools import wraps
from lnbits.core.crud import get_wallet_for_key
def check_wallet(requires_admin=False):
def wrap(view):
@wraps(view)
async def wrapped_view(**kwargs):
token = request.headers["Authorization"].split("Bearer ")[1]
key_type, key = b64decode(token).decode("utf-8").split(":")
if requires_admin and key_type != "admin":
return jsonify({"error": True, "code": 2, "message": "insufficient permissions"})
g.wallet = get_wallet_for_key(key, key_type)
if not g.wallet:
return jsonify({"error": True, "code": 2, "message": "insufficient permissions"})
return await view(**kwargs)
return wrapped_view
return wrap
+2
View File
@@ -0,0 +1,2 @@
def migrate():
pass
@@ -0,0 +1,35 @@
<q-expansion-item
group="extras"
icon="info"
label="Instructions"
default-opened
>
<q-card>
<q-card-section>
To access an LNbits wallet from a mobile phone,
<ol>
<li>
Install either <a href="https://zeusln.app">Zeus</a> or
<a href="https://bluewallet.io/">BlueWallet</a>;
</li>
<li>
Go to <code>Add a wallet / Import wallet</code> on BlueWallet or
<code>Settings / Add a new node</code> on Zeus.
</li>
<li>Select the desired wallet on this page;</li>
<li>Scan one of the two QR codes from the mobile wallet.</li>
</ol>
<ul>
<li>
<em>Invoice</em> URLs mean the mobile wallet will only have the
authorization to read your payments and invoices and generate new
invoices.
</li>
<li>
<em>Admin</em> URLs mean the mobile wallet will be able to pay
invoices..
</li>
</ul>
</q-card-section>
</q-card>
</q-expansion-item>
@@ -0,0 +1,19 @@
<q-expansion-item group="extras" icon="info" label="LndHub info">
<q-card>
<q-card-section>
<p>
LndHub is a protocol invented by
<a href="https://bluewallet.io/">BlueWallet</a> that allows mobile
wallets to query payments and balances, generate invoices and make
payments from accounts that exist on a server. The protocol is a
collection of HTTP endpoints exposed through the internet.
</p>
<p>
For a wallet that supports it, reading a QR code that contains the URL
along with secret access credentials should enable access. Currently it
is supported by <a href="https://zeusln.app">Zeus</a> and
<a href="https://bluewallet.io/">BlueWallet</a>.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
@@ -0,0 +1,92 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %} {% raw %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<div class="row q-col-gutter-md">
<q-card
v-for="type in ['invoice', 'admin']"
v-bind:key="type"
class="q-pa-sm q-ma-sm col-5"
>
<q-card-section class="q-pa-none">
<div class="text-center">
<a :href="selectedWallet[type]">
<q-responsive :ratio="1" class="q-mx-sm">
<qrcode
:value="selectedWallet[type]"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg items-center justify-center">
<q-btn
outline
color="grey"
@click="copyText(selectedWallet[type])"
class="text-center q-mb-md"
>Copy LndHub {{type}} URL</q-btn
>
</div>
</q-card-section>
</q-card>
</div>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-select
filled
dense
v-model="selectedWallet"
:options="wallets"
label="Select wallet:"
>
</q-select>
</q-form>
</q-card>
</div>
{% endraw %}
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits LndHub extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "lndhub/_instructions.html" %}
<q-separator></q-separator>
{% include "lndhub/_lndhub.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
var wallets = JSON.parse('{{ g.user.wallets | tojson }}')
.map(LNbits.map.wallet)
.map(wallet => ({
label: wallet.name,
admin: `lndhub://admin:${wallet.adminkey}@${location.protocol}//${location.host}/lndhub/ext/`,
invoice: `lndhub://invoice:${wallet.inkey}@${location.protocol}//${location.host}/lndhub/ext/`
}))
return {
wallets: wallets,
selectedWallet: wallets[0]
}
}
})
</script>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
from binascii import unhexlify
from lnbits.bolt11 import Invoice
def to_buffer(payment_hash: str):
return {"type": "Buffer", "data": [b for b in unhexlify(payment_hash)]}
def decoded_as_lndhub(invoice: Invoice):
return {
"destination": invoice.payee,
"payment_hash": invoice.payment_hash,
"num_satoshis": invoice.amount_msat / 1000,
"timestamp": str(invoice.date),
"expiry": str(invoice.expiry),
"description": invoice.description,
"fallback_addr": "",
"cltv_expiry": invoice.min_final_cltv_expiry,
"route_hints": "",
}
+11
View File
@@ -0,0 +1,11 @@
from quart import render_template, g
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.lndhub import lndhub_ext
@lndhub_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def lndhub_index():
return await render_template("lndhub/index.html", user=g.user)
+202
View File
@@ -0,0 +1,202 @@
import time
from base64 import urlsafe_b64encode
from quart import jsonify, g, request
from lnbits.core.services import pay_invoice, create_invoice
from lnbits.core.crud import delete_expired_invoices
from lnbits.decorators import api_validate_post_request
from lnbits.settings import WALLET
from lnbits import bolt11
from lnbits.extensions.lndhub import lndhub_ext
from .decorators import check_wallet
from .utils import to_buffer, decoded_as_lndhub
@lndhub_ext.route("/ext/getinfo", methods=["GET"])
async def lndhub_getinfo():
return jsonify({"error": True, "code": 1, "message": "bad auth"})
@lndhub_ext.route("/ext/auth", methods=["POST"])
@api_validate_post_request(
schema={
"login": {"type": "string", "required": True, "excludes": "refresh_token"},
"password": {"type": "string", "required": True, "excludes": "refresh_token"},
"refresh_token": {"type": "string", "required": True, "excludes": ["login", "password"]},
}
)
async def lndhub_auth():
token = (
g.data["refresh_token"]
if "refresh_token" in g.data and g.data["refresh_token"]
else urlsafe_b64encode((g.data["login"] + ":" + g.data["password"]).encode("utf-8")).decode("ascii")
)
return jsonify({"refresh_token": token, "access_token": token})
@lndhub_ext.route("/ext/addinvoice", methods=["POST"])
@check_wallet()
@api_validate_post_request(
schema={
"amt": {"type": "string", "required": True},
"memo": {"type": "string", "required": True},
"preimage": {"type": "string", "required": False},
}
)
async def lndhub_addinvoice():
try:
_, pr = create_invoice(
wallet_id=g.wallet.id,
amount=int(g.data["amt"]),
memo=g.data["memo"],
extra={"tag": "lndhub"},
)
except Exception as e:
return jsonify(
{
"error": True,
"code": 7,
"message": "Failed to create invoice: " + str(e),
}
)
invoice = bolt11.decode(pr)
return jsonify(
{
"pay_req": pr,
"payment_request": pr,
"add_index": "500",
"r_hash": to_buffer(invoice.payment_hash),
"hash": invoice.payment_hash,
}
)
@lndhub_ext.route("/ext/payinvoice", methods=["POST"])
@check_wallet(requires_admin=True)
@api_validate_post_request(schema={"invoice": {"type": "string", "required": True}})
async def lndhub_payinvoice():
try:
pay_invoice(
wallet_id=g.wallet.id,
payment_request=g.data["invoice"],
extra={"tag": "lndhub"},
)
except Exception as e:
return jsonify(
{
"error": True,
"code": 10,
"message": "Payment failed: " + str(e),
}
)
invoice: bolt11.Invoice = bolt11.decode(g.data["invoice"])
return jsonify(
{
"payment_error": "",
"payment_preimage": "0" * 64,
"route": {},
"payment_hash": invoice.payment_hash,
"decoded": decoded_as_lndhub(invoice),
"fee_msat": 0,
"type": "paid_invoice",
"fee": 0,
"value": invoice.amount_msat / 1000,
"timestamp": int(time.time()),
"memo": invoice.description,
}
)
@lndhub_ext.route("/ext/balance", methods=["GET"])
@check_wallet()
async def lndhub_balance():
return jsonify({"BTC": {"AvailableBalance": g.wallet.balance}})
@lndhub_ext.route("/ext/gettxs", methods=["GET"])
@check_wallet()
async def lndhub_gettxs():
for payment in g.wallet.get_payments(
complete=False, pending=True, outgoing=True, incoming=False, exclude_uncheckable=True
):
payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
limit = int(request.args.get("limit", 200))
return jsonify(
[
{
"payment_preimage": payment.preimage,
"payment_hash": payment.payment_hash,
"fee_msat": payment.fee * 1000,
"type": "paid_invoice",
"fee": payment.fee,
"value": int(payment.amount / 1000),
"timestamp": payment.time,
"memo": payment.memo if not payment.pending else "Payment in transition",
}
for payment in reversed(
g.wallet.get_payments(pending=True, complete=True, outgoing=True, incoming=False)[:limit]
)
]
)
@lndhub_ext.route("/ext/getuserinvoices", methods=["GET"])
@check_wallet()
async def lndhub_getuserinvoices():
delete_expired_invoices()
for invoice in g.wallet.get_payments(
complete=False, pending=True, outgoing=False, incoming=True, exclude_uncheckable=True
):
invoice.set_pending(WALLET.get_invoice_status(invoice.checking_id).pending)
limit = int(request.args.get("limit", 200))
return jsonify(
[
{
"r_hash": to_buffer(invoice.payment_hash),
"payment_request": invoice.bolt11,
"add_index": "500",
"description": invoice.memo,
"payment_hash": invoice.payment_hash,
"ispaid": not invoice.pending,
"amt": int(invoice.amount / 1000),
"expire_time": int(time.time() + 1800),
"timestamp": invoice.time,
"type": "user_invoice",
}
for invoice in reversed(
g.wallet.get_payments(pending=True, complete=True, incoming=True, outgoing=False)[:limit]
)
]
)
@lndhub_ext.route("/ext/getbtc", methods=["GET"])
@check_wallet()
async def lndhub_getbtc():
"load an address for incoming onchain btc"
return jsonify([])
@lndhub_ext.route("/ext/getpending", methods=["GET"])
@check_wallet()
async def lndhub_getpending():
"pending onchain transactions"
return jsonify([])
@lndhub_ext.route("/ext/decodeinvoice", methods=["GET"])
async def lndhub_decodeinvoice():
invoice = request.args.get("invoice")
inv = bolt11.decode(invoice)
return jsonify(decoded_as_lndhub(inv))
@lndhub_ext.route("/ext/checkrouteinvoice", methods=["GET"])
async def lndhub_checkrouteinvoice():
"not implemented on canonical lndhub"
pass
+1 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint
from quart import Blueprint
lnticket_ext: Blueprint = Blueprint("lnticket", __name__, static_folder="static", template_folder="templates")
+15 -15
View File
@@ -6,36 +6,36 @@ from lnbits.helpers import urlsafe_short_hash
from .models import Tickets, Forms
#######TICKETS########
def create_ticket(checking_id: str, wallet: str, form: str, name: str, email: str, ltext: str, sats: int) -> Tickets:
def create_ticket(payment_hash: str, wallet: str, form: str, name: str, email: str, ltext: str, sats: int) -> Tickets:
with open_ext_db("lnticket") as db:
db.execute(
"""
INSERT INTO ticket (id, form, email, ltext, name, wallet, sats, paid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(checking_id, form, email, ltext, name, wallet, sats, False),
(payment_hash, form, email, ltext, name, wallet, sats, False),
)
return get_ticket(checking_id)
return get_ticket(payment_hash)
def update_ticket(paid: bool, checking_id: str) -> Tickets:
def update_ticket(paid: bool, payment_hash: str) -> Tickets:
with open_ext_db("lnticket") as db:
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (checking_id,))
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
if row[7] == True:
return get_ticket(checking_id)
return get_ticket(payment_hash)
db.execute(
"""
UPDATE ticket
SET paid = ?
WHERE id = ?
""",
(paid, checking_id),
(paid, payment_hash),
)
formdata = get_form(row[1])
amount = formdata.amountmade + row[7]
db.execute(
@@ -46,7 +46,8 @@ def update_ticket(paid: bool, checking_id: str) -> Tickets:
""",
(amount, row[1]),
)
return get_ticket(checking_id)
return get_ticket(payment_hash)
def get_ticket(ticket_id: str) -> Optional[Tickets]:
with open_ext_db("lnticket") as db:
@@ -71,8 +72,6 @@ def delete_ticket(ticket_id: str) -> None:
db.execute("DELETE FROM ticket WHERE id = ?", (ticket_id,))
########FORMS#########
@@ -84,11 +83,12 @@ def create_form(*, wallet: str, name: str, description: str, costpword: int) ->
INSERT INTO forms (id, wallet, name, description, costpword, amountmade)
VALUES (?, ?, ?, ?, ?, ?)
""",
(form_id, wallet, name, description, costpword, 0 ),
(form_id, wallet, name, description, costpword, 0),
)
return get_form(form_id)
def update_form(form_id: str, **kwargs) -> Forms:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("lnticket") as db:
@@ -118,4 +118,4 @@ def get_forms(wallet_ids: Union[str, List[str]]) -> List[Forms]:
def delete_form(form_id: str) -> None:
with open_ext_db("lnticket") as db:
db.execute("DELETE FROM forms WHERE id = ?", (form_id,))
db.execute("DELETE FROM forms WHERE id = ?", (form_id,))
+8 -17
View File
@@ -1,5 +1,3 @@
from lnbits.db import open_ext_db
def m001_initial(db):
db.execute(
@@ -50,7 +48,6 @@ def m002_changed(db):
"""
)
for row in [list(row) for row in db.fetchall("SELECT * FROM tickets")]:
usescsv = ""
@@ -59,7 +56,7 @@ def m002_changed(db):
usescsv += "," + str(i + 1)
else:
usescsv += "," + str(1)
usescsv = usescsv[1:]
usescsv = usescsv[1:]
db.execute(
"""
INSERT INTO ticket (
@@ -75,20 +72,14 @@ def m002_changed(db):
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
row[0],
row[1],
row[2],
row[0],
row[1],
row[2],
row[3],
row[4],
row[5],
row[6],
True,
row[4],
row[5],
row[6],
True,
),
)
db.execute("DROP TABLE tickets")
def migrate():
with open_ext_db("lnticket") as db:
m001_initial(db)
m002_changed(db)
-1
View File
@@ -21,4 +21,3 @@ class Tickets(NamedTuple):
sats: int
paid: bool
time: int
@@ -76,7 +76,6 @@
</div>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
console.log('{{ form_costpword }}')
Vue.component(VueQrcode.name, VueQrcode)
@@ -106,15 +105,15 @@
computed: {
amountWords() {
var regex = /\s+/gi
var char = this.formDialog.data.text
var nwords = this.formDialog.data.text
.trim()
.replace(regex, ' ')
.split(' ').length
this.formDialog.data.sats = char * parseInt('{{ form_costpword }}')
if (this.formDialog.data.sats == parseInt('{{ form_costpword }}')) {
var sats = nwords * parseInt('{{ form_costpword }}')
if (sats === parseInt('{{ form_costpword }}')) {
return '0 Sats to pay'
} else {
return this.formDialog.data.sats + ' Sats to pay'
return sats + ' Sats to pay'
}
}
},
@@ -125,7 +124,6 @@
this.formDialog.data.name = ''
this.formDialog.data.email = ''
this.formDialog.data.text = ''
this.formDialog.data.sats = 0
},
closeReceiveDialog: function () {
@@ -138,21 +136,15 @@
Invoice: function () {
var self = this
axios
.post(
'/lnticket/api/v1/tickets/' +
'{{ form_id }}/' +
self.formDialog.data.sats,
{
form: '{{ form_id }}',
name: self.formDialog.data.name,
email: self.formDialog.data.email,
ltext: self.formDialog.data.text,
sats: self.formDialog.data.sats
}
)
.post('/lnticket/api/v1/tickets/{{ form_id }}', {
form: '{{ form_id }}',
name: self.formDialog.data.name,
email: self.formDialog.data.email,
ltext: self.formDialog.data.text
})
.then(function (response) {
self.paymentReq = response.data.payment_request
self.paymentCheck = response.data.checking_id
self.paymentCheck = response.data.payment_hash
dismissMsg = self.$q.notify({
timeout: 0,
@@ -175,7 +167,6 @@
self.formDialog.data.name = ''
self.formDialog.data.email = ''
self.formDialog.data.text = ''
self.formDialog.data.sats = 0
self.$q.notify({
type: 'positive',
@@ -157,9 +157,7 @@
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "lnticket/_api_docs.html" %}
</q-list>
<q-list> {% include "lnticket/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
+11 -5
View File
@@ -1,4 +1,4 @@
from flask import g, abort, render_template
from quart import g, abort, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from http import HTTPStatus
@@ -10,13 +10,19 @@ from .crud import get_form
@lnticket_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def index():
return render_template("lnticket/index.html", user=g.user)
async def index():
return await render_template("lnticket/index.html", user=g.user)
@lnticket_ext.route("/<form_id>")
def display(form_id):
async def display(form_id):
form = get_form(form_id) or abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
print(form.id)
return render_template("lnticket/display.html", form_id=form.id, form_name=form.name, form_desc=form.description, form_costpword=form.costpword)
return await render_template(
"lnticket/display.html",
form_id=form.id,
form_name=form.name,
form_desc=form.description,
form_costpword=form.costpword,
)
+48 -32
View File
@@ -1,20 +1,32 @@
from flask import g, jsonify, request
import re
from quart import g, jsonify, request
from http import HTTPStatus
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import create_invoice
from lnbits.core.services import create_invoice, check_invoice_status
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.settings import WALLET
from lnbits.extensions.lnticket import lnticket_ext
from .crud import create_ticket, update_ticket, get_ticket, get_tickets, delete_ticket, create_form, update_form, get_form, get_forms, delete_form
from .crud import (
create_ticket,
update_ticket,
get_ticket,
get_tickets,
delete_ticket,
create_form,
update_form,
get_form,
get_forms,
delete_form,
)
#########FORMS##########
@lnticket_ext.route("/api/v1/forms", methods=["GET"])
@api_check_wallet_key("invoice")
def api_forms():
async def api_forms():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
@@ -31,13 +43,12 @@ def api_forms():
"wallet": {"type": "string", "empty": False, "required": True},
"name": {"type": "string", "empty": False, "required": True},
"description": {"type": "string", "min": 0, "required": True},
"costpword": {"type": "integer", "min": 0, "required": True}
"costpword": {"type": "integer", "min": 0, "required": True},
}
)
def api_form_create(form_id=None):
async def api_form_create(form_id=None):
if form_id:
form = get_form(form_id)
print(g.data)
if not form:
return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND
@@ -53,7 +64,7 @@ def api_form_create(form_id=None):
@lnticket_ext.route("/api/v1/forms/<form_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
def api_form_delete(form_id):
async def api_form_delete(form_id):
form = get_form(form_id)
if not form:
@@ -66,11 +77,13 @@ def api_form_delete(form_id):
return "", HTTPStatus.NO_CONTENT
#########tickets##########
@lnticket_ext.route("/api/v1/tickets", methods=["GET"])
@api_check_wallet_key("invoice")
def api_tickets():
async def api_tickets():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
@@ -79,57 +92,60 @@ def api_tickets():
return jsonify([form._asdict() for form in get_tickets(wallet_ids)]), HTTPStatus.OK
@lnticket_ext.route("/api/v1/tickets/<form_id>/<sats>", methods=["POST"])
@lnticket_ext.route("/api/v1/tickets/<form_id>", methods=["POST"])
@api_validate_post_request(
schema={
"form": {"type": "string", "empty": False, "required": True},
"name": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": True, "required": True},
"ltext": {"type": "string", "empty": False, "required": True},
"sats": {"type": "integer", "min": 0, "required": True}
})
def api_ticket_make_ticket(form_id, sats):
event = get_form(form_id)
if not event:
}
)
async def api_ticket_make_ticket(form_id):
form = get_form(form_id)
if not form:
return jsonify({"message": "LNTicket does not exist."}), HTTPStatus.NOT_FOUND
try:
checking_id, payment_request = create_invoice(
wallet_id=event.wallet, amount=int(sats), memo=f"#lnticket {form_id}"
nwords = len(re.split(r"\s+", g.data["ltext"]))
sats = nwords * form.costpword
payment_hash, payment_request = create_invoice(
wallet_id=form.wallet,
amount=sats,
memo=f"ticket with {nwords} words on {form_id}",
extra={"tag": "lnticket"},
)
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
ticket = create_ticket(checking_id=checking_id, wallet=event.wallet, **g.data)
ticket = create_ticket(payment_hash=payment_hash, wallet=form.wallet, sats=sats, **g.data)
if not ticket:
return jsonify({"message": "LNTicket could not be fetched."}), HTTPStatus.NOT_FOUND
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK
@lnticket_ext.route("/api/v1/tickets/<checking_id>", methods=["GET"])
def api_ticket_send_ticket(checking_id):
theticket = get_ticket(checking_id)
@lnticket_ext.route("/api/v1/tickets/<payment_hash>", methods=["GET"])
async def api_ticket_send_ticket(payment_hash):
ticket = get_ticket(payment_hash)
try:
is_paid = not WALLET.get_invoice_status(checking_id).pending
is_paid = not check_invoice_status(ticket.wallet, payment_hash).pending
except Exception:
return jsonify({"message": "Not paid."}), HTTPStatus.NOT_FOUND
if is_paid:
wallet = get_wallet(theticket.wallet)
payment = wallet.get_payment(checking_id)
wallet = get_wallet(ticket.wallet)
payment = wallet.get_payment(payment_hash)
payment.set_pending(False)
ticket = update_ticket(paid=True, checking_id=checking_id)
ticket = update_ticket(paid=True, payment_hash=payment_hash)
return jsonify({"paid": True, "ticket_id": ticket.id}), HTTPStatus.OK
return jsonify({"paid": False}), HTTPStatus.OK
@lnticket_ext.route("/api/v1/tickets/<ticket_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
def api_ticket_delete(ticket_id):
async def api_ticket_delete(ticket_id):
ticket = get_ticket(ticket_id)
if not ticket:
+1
View File
@@ -0,0 +1 @@
# LNURLp
+14
View File
@@ -0,0 +1,14 @@
from quart import Blueprint
lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
from .views import * # noqa
from .lnurl import * # noqa
from .tasks import register_listeners
from lnbits.tasks import record_async
lnurlp_ext.record(record_async(register_listeners))
+10
View File
@@ -0,0 +1,10 @@
{
"name": "LNURLp",
"short_description": "Make reusable LNURL pay links",
"icon": "receipt",
"contributors": [
"arcbtc",
"eillarra",
"fiatjaf"
]
}
+114
View File
@@ -0,0 +1,114 @@
import json
from typing import List, Optional, Union
from lnbits.db import open_ext_db
from lnbits.core.models import Payment
from quart import g
from .models import PayLink
def create_pay_link(
*,
wallet_id: str,
description: str,
min: int,
max: int,
comment_chars: int = 0,
currency: Optional[str] = None,
webhook_url: Optional[str] = None,
success_text: Optional[str] = None,
success_url: Optional[str] = None,
) -> Optional[PayLink]:
with open_ext_db("lnurlp") as db:
db.execute(
"""
INSERT INTO pay_links (
wallet,
description,
min,
max,
served_meta,
served_pr,
webhook_url,
success_text,
success_url,
comment_chars,
currency
)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
""",
(
wallet_id,
description,
min,
max,
webhook_url,
success_text,
success_url,
comment_chars,
currency,
),
)
link_id = db.cursor.lastrowid
return get_pay_link(link_id)
def get_pay_link(link_id: int) -> Optional[PayLink]:
with open_ext_db("lnurlp") as db:
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("lnurlp") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(
f"""
SELECT * FROM pay_links WHERE wallet IN ({q})
ORDER BY Id
""",
(*wallet_ids,),
)
return [PayLink.from_row(row) for row in rows]
def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("lnurlp") as db:
db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
with open_ext_db("lnurlp") as db:
db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
def delete_pay_link(link_id: int) -> None:
with open_ext_db("lnurlp") as db:
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status
g.db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)
+48
View File
@@ -0,0 +1,48 @@
import trio # type: ignore
import httpx
async def get_fiat_rate(currency: str):
assert currency == "USD", "Only USD is supported as fiat currency."
return await get_usd_rate()
async def get_usd_rate():
"""
Returns an average satoshi price from multiple sources.
"""
satoshi_prices = [None, None, None]
async def fetch_price(index, url, getter):
try:
async with httpx.AsyncClient() as client:
r = await client.get(url)
r.raise_for_status()
satoshi_price = int(100_000_000 / float(getter(r.json())))
satoshi_prices[index] = satoshi_price
except Exception:
pass
async with trio.open_nursery() as nursery:
nursery.start_soon(
fetch_price,
0,
"https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD",
lambda d: d["result"]["XXBTCZUSD"]["c"][0],
)
nursery.start_soon(
fetch_price,
1,
"https://www.bitstamp.net/api/v2/ticker/btcusd",
lambda d: d["last"],
)
nursery.start_soon(
fetch_price,
2,
"https://api.coincap.io/v2/rates/bitcoin",
lambda d: d["data"]["rateUsd"],
)
satoshi_prices = [x for x in satoshi_prices if x]
return sum(satoshi_prices) / len(satoshi_prices)
+88
View File
@@ -0,0 +1,88 @@
import hashlib
import math
from http import HTTPStatus
from quart import jsonify, url_for, request
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
from lnbits.core.services import create_invoice
from . import lnurlp_ext
from .crud import increment_pay_link
from .helpers import get_fiat_rate
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
async def api_lnurl_response(link_id):
link = increment_pay_link(link_id, served_meta=1)
if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
rate = await get_fiat_rate(link.currency) if link.currency else 1
resp = LnurlPayResponse(
callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True),
min_sendable=math.ceil(link.min * rate) * 1000,
max_sendable=round(link.max * rate) * 1000,
metadata=link.lnurlpay_metadata,
)
params = resp.dict()
if link.comment_chars > 0:
params["commentAllowed"] = link.comment_chars
return jsonify(params), HTTPStatus.OK
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"])
async def api_lnurl_callback(link_id):
link = increment_pay_link(link_id, served_pr=1)
if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
min, max = link.min, link.max
rate = await get_fiat_rate(link.currency) if link.currency else 1
if link.currency:
# allow some fluctuation (as the fiat price may have changed between the calls)
min = rate * 995 * link.min
max = rate * 1010 * link.max
else:
min = link.min * 1000
max = link.max * 1000
amount_received = int(request.args.get("amount"))
if amount_received < min:
return (
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is smaller than minimum {min}.").dict()),
HTTPStatus.OK,
)
elif amount_received > max:
return (
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict()),
HTTPStatus.OK,
)
comment = request.args.get("comment")
if len(comment or "") > link.comment_chars:
return (
jsonify(
LnurlErrorResponse(
reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
).dict()
),
HTTPStatus.OK,
)
payment_hash, payment_request = create_invoice(
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo=link.description,
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
extra={"tag": "lnurlp", "link": link.id, "comment": comment},
)
resp = LnurlPayActionResponse(
pr=payment_request,
success_action=link.success_action(payment_hash),
routes=[],
)
return jsonify(resp.dict()), HTTPStatus.OK
+48
View File
@@ -0,0 +1,48 @@
def m001_initial(db):
"""
Initial pay table.
"""
db.execute(
"""
CREATE TABLE IF NOT EXISTS pay_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wallet TEXT NOT NULL,
description TEXT NOT NULL,
amount INTEGER NOT NULL,
served_meta INTEGER NOT NULL,
served_pr INTEGER NOT NULL
);
"""
)
def m002_webhooks_and_success_actions(db):
"""
Webhooks and success actions.
"""
db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;")
db.execute("ALTER TABLE pay_links ADD COLUMN success_text TEXT;")
db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;")
db.execute(
"""
CREATE TABLE invoices (
pay_link INTEGER NOT NULL REFERENCES pay_links (id),
payment_hash TEXT NOT NULL,
webhook_sent INT, -- null means not sent, otherwise store status
expiry INT
);
"""
)
def m003_min_max_comment_fiat(db):
"""
Support for min/max amounts, comments and fiat prices that get
converted automatically to satoshis based on some API.
"""
db.execute("ALTER TABLE pay_links ADD COLUMN currency TEXT;") # null = satoshis
db.execute("ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;")
db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;")
db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;")
db.execute("UPDATE pay_links SET max = min;")
db.execute("DROP TABLE invoices")
+50
View File
@@ -0,0 +1,50 @@
import json
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
from quart import url_for
from typing import NamedTuple, Optional, Dict
from sqlite3 import Row
from lnurl import Lnurl, encode as lnurl_encode
from lnurl.types import LnurlPayMetadata
from lnurl.models import LnurlPaySuccessAction, MessageAction, UrlAction
class PayLink(NamedTuple):
id: int
wallet: str
description: str
min: int
served_meta: int
served_pr: int
webhook_url: str
success_text: str
success_url: str
currency: str
comment_chars: int
max: int
@classmethod
def from_row(cls, row: Row) -> "PayLink":
data = dict(row)
return cls(**data)
@property
def lnurl(self) -> Lnurl:
url = url_for("lnurlp.api_lnurl_response", link_id=self.id, _external=True)
return lnurl_encode(url)
@property
def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
if self.success_url:
url: ParseResult = urlparse(self.success_url)
qs: Dict = parse_qs(url.query)
qs["payment_hash"] = payment_hash
url = url._replace(query=urlencode(qs, doseq=True))
raw: str = urlunparse(url)
return UrlAction(url=raw, description=self.success_text)
elif self.success_text:
return MessageAction(message=self.success_text)
else:
return None
+207
View File
@@ -0,0 +1,207 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
Vue.component(VueQrcode.name, VueQrcode)
var locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
var mapPayLink = obj => {
obj._data = _.clone(obj)
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.print_url = [locationPath, 'print/', obj.id].join('')
obj.pay_url = [locationPath, obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
fiatRates: {},
checker: null,
payLinks: [],
payLinksTable: {
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
fixedAmount: true,
data: {}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
getPayLinks() {
LNbits.api
.request(
'GET',
'/lnurlp/api/v1/links?all_wallets',
this.g.user.wallets[0].inkey
)
.then(response => {
this.payLinks = response.data.map(mapPayLink)
})
.catch(err => {
clearInterval(this.checker)
LNbits.utils.notifyApiError(err)
})
},
closeFormDialog() {},
openQrCodeDialog(linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
if (link.currency) this.updateFiatRate(link.currency)
this.qrCodeDialog.data = {
id: link.id,
amount:
(link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
' ' +
(link.currency || 'sat'),
currency: link.currency,
comments: link.comment_chars
? `${link.comment_chars} characters`
: 'no',
webhook: link.webhook_url || 'nowhere',
success:
link.success_text || link.success_url
? 'Display message "' +
link.success_text +
'"' +
(link.success_url ? ' and URL "' + link.success_url + '"' : '')
: 'do nothing',
lnurl: link.lnurl,
pay_url: link.pay_url,
print_url: link.print_url
}
this.qrCodeDialog.show = true
},
openUpdateDialog(linkId) {
const link = _.findWhere(this.payLinks, {id: linkId})
if (link.currency) this.updateFiatRate(link.currency)
this.formDialog.data = _.clone(link._data)
this.formDialog.show = true
this.formDialog.fixedAmount =
this.formDialog.data.min === this.formDialog.data.max
},
sendFormData() {
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
if (this.formDialog.fixedAmount) data.max = data.min
if (data.currency === 'satoshis') data.currency = null
if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0
if (data.id) {
this.updatePayLink(wallet, data)
} else {
this.createPayLink(wallet, data)
}
},
updatePayLink(wallet, data) {
let values = _.omit(
_.pick(
data,
'description',
'min',
'max',
'webhook_url',
'success_text',
'success_url',
'comment_chars',
'currency'
),
(value, key) =>
(key === 'webhook_url' ||
key === 'success_text' ||
key === 'success_url') &&
(value === null || value === '')
)
LNbits.api
.request(
'PUT',
'/lnurlp/api/v1/links/' + data.id,
wallet.adminkey,
values
)
.then(response => {
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
this.payLinks.push(mapPayLink(response.data))
this.formDialog.show = false
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
createPayLink(wallet, data) {
LNbits.api
.request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
.then(response => {
this.payLinks.push(mapPayLink(response.data))
this.formDialog.show = false
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
deletePayLink: linkId => {
var link = _.findWhere(this.payLinks, {id: linkId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(() => {
LNbits.api
.request(
'DELETE',
'/lnurlp/api/v1/links/' + linkId,
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
)
.then(response => {
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
})
},
updateFiatRate(currency) {
LNbits.api
.request('GET', '/lnurlp/api/v1/rate/' + currency, null)
.then(response => {
let rates = _.clone(this.fiatRates)
rates[currency] = response.data.rate
this.fiatRates = rates
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
created() {
if (this.g.user.wallets.length) {
var getPayLinks = this.getPayLinks
getPayLinks()
this.checker = setInterval(() => {
getPayLinks()
}, 20000)
}
}
})
+47
View File
@@ -0,0 +1,47 @@
import trio # type: ignore
import httpx
from lnbits.core.models import Payment
from lnbits.tasks import run_on_pseudo_request, register_invoice_listener
from .crud import mark_webhook_sent, get_pay_link
async def register_listeners():
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
register_invoice_listener(invoice_paid_chan_send)
await wait_for_paid_invoices(invoice_paid_chan_recv)
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
await run_on_pseudo_request(on_invoice_paid, payment)
async def on_invoice_paid(payment: Payment) -> None:
if "lnurlp" != payment.extra.get("tag"):
# not an lnurlp invoice
return
if payment.extra.get("wh_status"):
# this webhook has already been sent
return
pay_link = get_pay_link(payment.extra.get("link", -1))
if pay_link and pay_link.webhook_url:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
pay_link.webhook_url,
json={
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": payment.amount,
"comment": payment.extra.get("comment"),
"lnurlp": pay_link.id,
},
timeout=40,
)
mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
mark_webhook_sent(payment, -1)
@@ -0,0 +1,129 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List pay links">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /api/v1/links</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;pay_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}pay/api/v1/links -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get a pay link">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create a pay link"
>
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /api/v1/links</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"description": &lt;string&gt; "amount": &lt;integer&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}pay/api/v1/links -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Update a pay link"
>
<q-card>
<q-card-section>
<code
><span class="text-green">PUT</span>
/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"description": &lt;string&gt;, "amount": &lt;integer&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt; -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a pay link"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

Some files were not shown because too many files have changed in this diff Show More