Compare commits

..
105 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
70 changed files with 3271 additions and 1117 deletions
+28 -22
View File
@@ -1,4 +1,4 @@
QUART_APP=lnbits.app
QUART_APP=lnbits.app:create_app()
QUART_ENV=development
QUART_DEBUG=true
@@ -8,43 +8,49 @@ PORT=5000
LNBITS_SITE_TITLE=LNbits
LNBITS_ALLOWED_USERS=""
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
LNBITS_DATA_FOLDER="/your_custom_data_folder" #IMPORTANT! i.e. "/home/satoshi/lnbits/lnbits/data"
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
@@ -3,7 +3,6 @@ FROM python:3.7-slim
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -q -r requirements.txt
RUN pip install --no-cache-dir -q hypercorn
COPY . /app
EXPOSE 5000
+5 -2
View File
@@ -13,17 +13,20 @@ ecdsa = "*"
environs = "*"
lnurl = "*"
pyscss = "*"
requests = "*"
shortuuid = "*"
quart = "*"
quart-cors = "*"
quart-compress = "*"
secure = "*"
typing-extensions = "*"
httpx = "*"
quart-trio = "*"
trio = "*"
hypercorn = {extras = ["trio"], version = "*"}
[dev-packages]
black = "==20.8b1"
pytest = "*"
pytest-cov = "*"
pytest-asyncio = "*"
mypy = "==0.761"
pytest-trio = "*"
Generated
+186 -79
View File
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "775e2ea809508c83df4696bba3a38a03e288cba22f0a7f562120230f40351ab9"
"sha256": "193ef930ac0906127fd292b2fc7ba5b4d786227b6795d182dad9b57448fca75e"
},
"pipfile-spec": 6,
"requires": {
@@ -23,6 +23,22 @@
],
"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",
@@ -90,13 +106,6 @@
],
"version": "==2020.6.20"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
@@ -123,46 +132,67 @@
},
"h11": {
"hashes": [
"sha256:311dc5478c2568cc07262e0381cdfc5b9c6ba19775905736c87e81ae6662b9fd",
"sha256:9eecfbafc980976dbff26a01dd3487644dd5d00f8038584451fc64a660f7c502"
"sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab",
"sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87"
],
"version": "==0.10.0"
"version": "==0.11.0"
},
"h2": {
"hashes": [
"sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5",
"sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"
"sha256:ac9e293a1990b339d5d71b19c5fe630e3dd4d768c620d1730d355485323f1b25",
"sha256:bb7ac7099dd67a857ed52c815a6192b6b1f5ba6b516237fc24a085341340593d"
],
"version": "==3.2.0"
"markers": "python_full_version >= '3.6.1'",
"version": "==4.0.0"
},
"hpack": {
"hashes": [
"sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89",
"sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"
"sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c",
"sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"
],
"version": "==3.0.0"
"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": "==0.16.1"
},
"hypercorn": {
"hashes": [
"sha256:19f32e7267225c8108ad585b2c5deddf1fe75950797a0e87a682a3a00ef1af95",
"sha256:809d77f3bf9fa0794a598d8dfa0f8d889e7e1c2f927581cd33068803169dc474"
"extras": [
"trio"
],
"markers": "python_version >= '3.7'",
"version": "==0.10.2"
"hashes": [
"sha256:81c69dd84a87b8e8b3ebf06ef5dd92836a8238f0ac65ded3d86befb8ba9acfeb",
"sha256:e3f317d6d64d15ce589f49e4f5057947259fa35332d169e62cb060e9997189e4"
],
"index": "pypi",
"version": "==0.11.1"
},
"hyperframe": {
"hashes": [
"sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40",
"sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"
"sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1",
"sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34"
],
"version": "==5.2.0"
"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": {
@@ -235,6 +265,14 @@
"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",
@@ -303,13 +341,23 @@
"index": "pypi",
"version": "==0.3.0"
},
"requests": {
"quart-trio": {
"hashes": [
"sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
"sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
"sha256:00f3b20f8d82ce7e81ead61db4efba38ed7653c7e28199defded46b663ab2595",
"sha256:dafc8f0440d4b70fa60d24122a161d2373894d2bfa9f713d9f1df1fd508f0834"
],
"index": "pypi",
"version": "==2.24.0"
"version": "==0.5.1"
},
"rfc3986": {
"extras": [
"idna2008"
],
"hashes": [
"sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d",
"sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"
],
"version": "==1.4.0"
},
"secure": {
"hashes": [
@@ -335,6 +383,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",
@@ -342,6 +405,14 @@
],
"version": "==0.10.1"
},
"trio": {
"hashes": [
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
],
"index": "pypi",
"version": "==0.17.0"
},
"typing-extensions": {
"hashes": [
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
@@ -351,14 +422,6 @@
"index": "pypi",
"version": "==3.7.4.3"
},
"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"
},
"werkzeug": {
"hashes": [
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",
@@ -384,6 +447,14 @@
],
"version": "==1.4.4"
},
"async-generator": {
"hashes": [
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
],
"markers": "python_version >= '3.5'",
"version": "==1.10"
},
"attrs": {
"hashes": [
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
@@ -447,6 +518,13 @@
"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.3"
},
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
"version": "==2.10"
},
"iniconfig": {
"hashes": [
"sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
@@ -454,14 +532,6 @@
],
"version": "==1.0.1"
},
"more-itertools": {
"hashes": [
"sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
"sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
],
"markers": "python_version >= '3.5'",
"version": "==8.5.0"
},
"mypy": {
"hashes": [
"sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
@@ -489,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",
@@ -530,19 +608,11 @@
},
"pytest": {
"hashes": [
"sha256:0e37f61339c4578776e090c3b8f6b16ce4db333889d65d0efb305243ec544b40",
"sha256:c8f57c2a30983f469bf03e68cdfa74dc474ce56b8f280ddcb080dfd91df01043"
"sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9",
"sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92"
],
"index": "pypi",
"version": "==6.0.2"
},
"pytest-asyncio": {
"hashes": [
"sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d",
"sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"
],
"index": "pypi",
"version": "==0.14.0"
"version": "==6.1.1"
},
"pytest-cov": {
"hashes": [
@@ -552,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": [
@@ -586,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",
@@ -593,6 +692,14 @@
],
"version": "==0.10.1"
},
"trio": {
"hashes": [
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
],
"index": "pypi",
"version": "==0.17.0"
},
"typed-ast": {
"hashes": [
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
+1 -1
View File
@@ -1 +1 @@
web: hypercorn --bind 0.0.0.0:5000 lnbits:app
web: hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
+7 -3
View File
@@ -22,7 +22,7 @@ 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.
@@ -30,9 +30,13 @@ Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbS
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 locally
## Running LNbits
See the [development docs](docs/devs/installation.md) for details on installation and setup.
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
+3 -1
View File
@@ -1,5 +1,7 @@
{
"scripts": {
"dokku": {}
"dokku": {
"predeploy": "quart migrate && quart assets"
}
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ nav_order: 1
Installation
============
Download this repo and install the dependencies.
Download the latest stable release https://github.com/lnbits/lnbits/releases
Application dependencies
@@ -29,12 +29,12 @@ If you wish to use a version of Python higher than 3.7:
$ 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 wallet](../guide/wallets.md) you use.
E.g. when you want to use LND you have to `pipenv run pip install lnd-grpc`.
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.
+12 -15
View File
@@ -11,23 +11,20 @@ Basic installation
Download this repo and install the dependencies:
```sh
$ git clone https://github.com/lnbits/lnbits.git
$ python3 -m venv .venv
$ source ./.venv/bin/activate
(.venv) $ pip install -r requirements.txt
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()'
```
You will need to set the variables in `.env.example`, and rename the file to `.env`.
No you can visit your LNbits at http://localhost:5000/.
Run the server:
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.
```sh
(.venv) $ python -m lnbits
```
Then you can run restart it and it will be using the new settings.
You might also need to install additional packages, depending on the [backend wallet](./wallets.md) you use.
E.g. when you want to use LND you have to run:
```sh
(.venv) $ pip install lnd-grpc
```
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.
+14 -20
View File
@@ -29,56 +29,50 @@ Using this wallet requires the installation of the `pylightning` Python package.
### 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`: e.g. https://lnbits.com
- `LNBITS_ADMIN_KEY`: apiKey
- `LNBITS_INVOICE_KEY`: apiKey
- `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`: https://lnpay.co/v1/
- `LNPAY_API_KEY`: apiKey
- `LNPAY_ADMIN_KEY`: apiKey
- `LNPAY_INVOICE_KEY`: apiKey
- `LNPAY_READ_KEY`: apiKey
- `LNPAY_API_KEY`: sak_apiKey
- `LNPAY_WALLET_KEY`: waka_apiKey
### lntxbot
- `LNBITS_BACKEND_WALLET_CLASS`: **LntxbotWallet**
- `LNTXBOT_API_ENDPOINT`: https://lntxbot.bigsun.xyz/
- `LNTXBOT_ADMIN_KEY`: apiKey
- `LNTXBOT_INVOICE_KEY`: apiKey
- `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`: https://api.opennode.com/
- `OPENNODE_ADMIN_KEY`: apiKey
- `OPENNODE_INVOICE_KEY`: apiKey
- `OPENNODE_KEY`: opennodeAdminApiKey
+13
View File
@@ -1,9 +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"])
+62 -14
View File
@@ -1,57 +1,88 @@
import importlib
import warnings
from quart import Quart, g
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
from .commands import db_migrate, handle_assets
from .core import core_app
from .db import open_db
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 ProxyFix
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") -> Quart:
def create_app(config_object="lnbits.settings") -> QuartTrio:
"""Create application factory.
:param config_object: The configuration object to use.
"""
app = Quart(__name__, static_folder="static")
app = QuartTrio(__name__, static_folder="static")
app.config.from_object(config_object)
app.asgi_http_class = ASGIProxyFix
cors(app)
Compress(app)
ProxyFix(app, x_proto=1, x_host=1)
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 register_blueprints(app: Quart) -> None:
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}")
app.register_blueprint(getattr(ext_module, f"{ext.code}_ext"), url_prefix=f"/{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: Quart):
def register_commands(app: QuartTrio):
"""Register Click commands."""
app.cli.add_command(db_migrate)
app.cli.add_command(handle_assets)
def register_assets(app: Quart):
def register_assets(app: QuartTrio):
"""Serve each vendored asset separately or a bundle."""
@app.before_request
@@ -64,18 +95,19 @@ def register_assets(app: Quart):
g.VENDORED_CSS = ["/static/bundle.css"]
def register_filters(app: Quart):
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: Quart):
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):
@@ -84,4 +116,20 @@ def register_request_hooks(app: Quart):
@app.teardown_request
async def after_request(exc):
g.db.__exit__(type(exc), exc, None)
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
+8 -5
View File
@@ -1,11 +1,10 @@
import warnings
import click
import importlib
import re
import os
import sqlite3
from scss.compiler import compile_string # type: ignore
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
@@ -24,9 +23,13 @@ def handle_assets():
def transpile_scss():
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()))
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():
+5 -1
View File
@@ -8,4 +8,8 @@ core_app: Blueprint = Blueprint(
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))
+13
View File
@@ -131,6 +131,19 @@ def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
# ---------------
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(
"""
+28
View File
@@ -1,7 +1,12 @@
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):
id: str
@@ -31,6 +36,16 @@ class Wallet(NamedTuple):
def balance(self) -> int:
return self.balance_msat // 1000
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
@@ -68,6 +83,7 @@ class Payment(NamedTuple):
preimage: str
payment_hash: str
extra: Dict
wallet_id: str
@classmethod
def from_row(cls, row: Row):
@@ -82,6 +98,7 @@ class Payment(NamedTuple):
fee=row["fee"],
memo=row["memo"],
time=row["time"],
wallet_id=row["wallet"],
)
@property
@@ -113,6 +130,17 @@ class Payment(NamedTuple):
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
+124 -13
View File
@@ -1,5 +1,12 @@
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
try:
from typing import TypedDict # type: ignore
@@ -9,7 +16,7 @@ except ImportError: # pragma: nocover
from lnbits import bolt11
from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import WALLET
from lnbits.wallets.base import PaymentStatus
from lnbits.wallets.base import PaymentStatus, PaymentResponse
from .crud import get_wallet, create_payment, delete_payment, check_internal, update_payment_status, get_wallet_payment
@@ -17,7 +24,7 @@ from .crud import get_wallet, create_payment, delete_payment, check_internal, up
def create_invoice(
*,
wallet_id: str,
amount: int,
amount: int, # in satoshis
memo: str,
description_hash: Optional[bytes] = None,
extra: Optional[Dict] = None,
@@ -49,7 +56,12 @@ def create_invoice(
def pay_invoice(
*, wallet_id: str, payment_request: str, max_sat: Optional[int] = None, extra: Optional[Dict] = None
*,
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()}"
internal_id = f"internal_{urlsafe_short_hash()}"
@@ -77,13 +89,13 @@ def pay_invoice(
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
memo=invoice.description or "",
memo=description or invoice.description or "",
extra=extra,
)
# check_internal() returns the checking_id of the invoice we're waiting for
internal = check_internal(invoice.payment_hash)
if internal:
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:
@@ -94,31 +106,130 @@ def pay_invoice(
# do the balance check
wallet = get_wallet(wallet_id)
assert wallet, "invalid wallet id"
assert wallet
if wallet.balance_msat < 0:
g.db.rollback()
raise PermissionError("Insufficient balance.")
else:
g.db.commit()
if internal:
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, pending=False)
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
ok, checking_id, fee_msat, error_message = WALLET.pay_invoice(payment_request)
if ok:
create_payment(checking_id=checking_id, fee=fee_msat, **payment_kwargs)
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(error_message or "Failed to pay_invoice on backend.")
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}},
)
async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
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,
)
def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus:
payment = get_wallet_payment(wallet_id, payment_hash)
if not payment:
+344 -119
View File
@@ -1,4 +1,4 @@
/* globals decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart */
/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart, decryptLnurlPayAES */
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
@@ -14,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
@@ -25,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
@@ -122,22 +116,30 @@ new Vue({
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: [
@@ -160,6 +162,12 @@ new Vue({
label: 'Amount (sat)',
field: 'sat',
sortable: true
},
{
name: 'fee',
align: 'right',
label: 'Fee (msat)',
field: 'fee'
}
],
pagination: {
@@ -173,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
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
@@ -206,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.payment_hash)
.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
}
@@ -314,7 +409,7 @@ 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') {
cleanInvoice.hash = tag.value
@@ -333,68 +428,191 @@ new Vue({
}
})
this.send.invoice = Object.freeze(cleanInvoice)
this.parse.invoice = Object.freeze(cleanInvoice)
},
payInvoice: function () {
var self = this
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.payment_hash)
.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()
})
},
@@ -404,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')
@@ -419,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)
+26
View File
@@ -4,6 +4,32 @@
label="API info"
:content-inset-level="0.5"
>
<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
+228 -88
View File
@@ -1,23 +1,30 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user, wallet) }}
{% extends "base.html" %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user, wallet) }}
<script src="/core/static/js/wallet.js"></script>
{% endblock %} {% block page %}
{% 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
<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="purple"
color="deep-purple"
class="full-width"
@click="showSendDialog"
>Send</q-btn
@click="showParseDialog"
>Paste Request</q-btn
>
</div>
<div class="col">
@@ -26,9 +33,19 @@
color="deep-purple"
class="full-width"
@click="showReceiveDialog"
>Receive</q-btn
>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>
@@ -85,7 +102,7 @@
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="lnbits__q-table__icon-td">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.isPaid"
size="14px"
@@ -102,7 +119,11 @@
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
<q-td key="memo" :props="props">
<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"
@@ -114,50 +135,59 @@
{{ props.row.memo }}
</q-td>
<q-td auto-width key="date" :props="props">
{{ props.row.date }}
<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>
<q-dialog v-model="props.expand" :props="props">
<q-card
v-if="props.row.amount > 0 && props.row.pending"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<q-card 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-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isPaid && props.row.isIn">
<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
@@ -166,14 +196,16 @@
:color="'pink'"
></q-icon>
Payment Sent
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
Outgoing payment pending
</div>
<q-tooltip>Payment Hash</q-tooltip>
<div class="text-wrap mono q-pa-md">
{{ props.row.payment_hash }}
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
@@ -184,7 +216,6 @@
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
@@ -228,18 +259,26 @@
</div>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<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
@@ -254,8 +293,12 @@
color="deep-purple"
:disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit"
>Create invoice</q-btn
>
<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
@@ -284,36 +327,143 @@
<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="send.show" position="top" @hide="closeSendDialog">
<q-dialog v-model="parse.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!send.invoice">
<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 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-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="!sendCamera.show"
@submit="decodeInvoice"
v-if="!parse.camera.show"
@submit="decodeRequest"
class="q-gutter-md"
>
<q-input
filled
dense
v-model.trim="send.data.bolt11"
v-model.trim="parse.data.request"
type="textarea"
label="Paste an invoice *"
label="Paste an invoice, payment request or lnurl code *"
>
<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 == ''"
:disable="parse.data.request == ''"
type="submit"
>Read invoice</q-btn
>Read</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
@@ -328,39 +478,29 @@
></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
<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-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>
+275 -24
View File
@@ -1,13 +1,36 @@
from quart 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 import bolt11
from lnbits.core import core_app
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.core.crud import delete_expired_invoices
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.settings import WALLET
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"])
@@ -17,12 +40,9 @@ async def api_payments():
delete_expired_invoices()
for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=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)
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")
@@ -31,6 +51,7 @@ async def api_payments():
"amount": {"type": "integer", "min": 1, "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},
}
)
async def api_payments_create_invoice():
@@ -50,6 +71,27 @@ async def api_payments_create_invoice():
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
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(
{
@@ -57,6 +99,7 @@ async def api_payments_create_invoice():
"payment_request": payment_request,
# maintain backwards compatibility with API clients:
"checking_id": invoice.payment_hash,
"lnurl_response": lnurl_response,
}
),
HTTPStatus.CREATED,
@@ -72,10 +115,10 @@ async def api_payments_pay_invoice():
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
except PermissionError as e:
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
except Exception as e:
print(e)
except Exception as exc:
traceback.print_exc(7)
g.db.rollback()
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR
return (
jsonify(
@@ -97,6 +140,88 @@ async def api_payments_create():
return await api_payments_create_invoice()
@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")
async def api_payment(payment_hash):
@@ -105,20 +230,146 @@ async def api_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_uncheckable:
pass
elif payment.is_out:
is_paid = not WALLET.get_payment_status(payment.checking_id).pending
elif payment.is_in:
is_paid = not WALLET.get_invoice_status(payment.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
+39 -6
View File
@@ -1,10 +1,14 @@
from quart import g, abort, redirect, request, render_template, send_from_directory, url_for
from http import HTTPStatus
import trio # type: ignore
import httpx
from os import path
from http import HTTPStatus
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 LNBITS_ALLOWED_USERS, SERVICE_FEE
from lnbits.tasks import run_on_pseudo_request
from ..crud import (
create_account,
@@ -13,6 +17,7 @@ from ..crud import (
create_wallet,
delete_wallet,
)
from ..services import redeem_lnurl_withdraw
@core_app.route("/favicon.ico")
@@ -73,12 +78,11 @@ async 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 await 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")
@@ -98,3 +102,32 @@ async 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))
-66
View File
@@ -1,66 +0,0 @@
import requests
from quart import abort, redirect, request, url_for
from http import HTTPStatus
from time import sleep
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore
from lnurl.exceptions import LnurlException # type: ignore
from lnbits import bolt11
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")
async 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.")
inv = bolt11.decode(payment_request)
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,
payment_request=payment_request,
payment_hash=inv.payment_hash,
)
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
+10 -1
View File
@@ -10,20 +10,29 @@ 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):
if self.closed:
return
if exc_val:
self.connection.rollback()
self.cursor.close()
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()
+3 -2
View File
@@ -9,12 +9,13 @@ 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)
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."}),
+3 -3
View File
@@ -1,4 +1,4 @@
import requests
import httpx
from quart import g, jsonify, request, abort
from http import HTTPStatus
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl
@@ -38,12 +38,12 @@ async def api_amilkit(amilk_id):
wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo, extra={"tag": "amilk"}
)
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):
+3 -3
View File
@@ -1,7 +1,7 @@
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
@@ -120,7 +120,7 @@ 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")
@@ -158,7 +158,7 @@ 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(
@@ -23,7 +23,6 @@
label="Your email "
></q-input>
<p>{% raw %}{{amountWords}}{% endraw %}</p>
<div class="row q-mt-lg">
<q-btn
unelevated
+3 -1
View File
@@ -3,7 +3,9 @@
# 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 quart import jsonify
from http import HTTPStatus
+2 -2
View File
@@ -28,8 +28,8 @@ async def lndhub_getinfo():
)
async def lndhub_auth():
token = (
g.data["token"]
if "token" in g.data and g.data["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})
+6
View File
@@ -6,3 +6,9 @@ lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", te
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))
+57 -17
View File
@@ -1,55 +1,84 @@
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, amount: int) -> 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,
amount,
min,
max,
served_meta,
served_pr
served_pr,
webhook_url,
success_text,
success_url,
comment_chars,
currency
)
VALUES (?, ?, ?, 0, 0)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
""",
(wallet_id, description, amount),
(
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: str) -> Optional[PayLink]:
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_link_by_hash(unique_hash: str) -> Optional[PayLink]:
with open_ext_db("lnurlp") as db:
row = db.fetchone("SELECT * FROM pay_links WHERE unique_hash = ?", (unique_hash,))
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})", (*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: str, **kwargs) -> Optional[PayLink]:
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:
@@ -59,7 +88,7 @@ def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
return PayLink.from_row(row) if row else None
def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
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:
@@ -69,6 +98,17 @@ def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
return PayLink.from_row(row) if row else None
def delete_pay_link(link_id: str) -> 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
+32
View File
@@ -14,3 +14,35 @@ def m001_initial(db):
);
"""
)
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")
+25 -4
View File
@@ -1,18 +1,26 @@
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 sqlite3 import Row
from typing import NamedTuple
from lnurl.models import LnurlPaySuccessAction, MessageAction, UrlAction
class PayLink(NamedTuple):
id: str
id: int
wallet: str
description: str
amount: int
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":
@@ -27,3 +35,16 @@ class PayLink(NamedTuple):
@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)
@@ -26,9 +26,7 @@
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mb-sm q-mt-none">LNbits LNURL-pay link</h6>
<p class="q-my-none">
Use a LNURL compatible bitcoin wallet to claim the sats.
</p>
<p class="q-my-none">Use an LNURL compatible bitcoin wallet to pay.</p>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
@@ -16,25 +16,22 @@
<div class="col">
<h5 class="text-subtitle1 q-my-none">Pay links</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="payLinks"
row-key="id"
:columns="payLinksTable.columns"
:pagination.sync="payLinksTable.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-th auto-width>Description</q-th>
<q-th auto-width>Amount</q-th>
<q-th auto-width>Currency</q-th>
<q-th auto-width></q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
@@ -60,8 +57,39 @@
@click="openQrCodeDialog(props.row.id)"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
<q-td auto-width>{{ props.row.description }}</q-td>
<q-td auto-width>
<span v-if="props.row.min == props.row.max">
{{ props.row.min }}
</span>
<span v-else>{{ props.row.min }} - {{ props.row.max }}</span>
</q-td>
<q-td>{{ props.row.currency || 'sat' }}</q-td>
<q-td>
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
</q-icon>
<q-icon
v-if="props.row.success_text || props.row.success_url"
size="14px"
name="call_to_action"
>
<q-tooltip>
On success, show message '{{ props.row.success_text }}'
<span v-if="props.row.success_url"
>and URL '{{ props.row.success_url }}'</span
>
</q-tooltip>
</q-icon>
<q-icon
v-if="props.row.comment_chars > 0"
size="14px"
name="insert_comment"
>
<q-tooltip>
{{ props.row.comment_chars }}-char comment allowed
</q-tooltip>
</q-icon>
</q-td>
<q-td auto-width>
<q-btn
@@ -124,12 +152,76 @@
type="text"
label="Item description *"
></q-input>
<div class="row q-col-gutter-sm">
<q-input
filled
dense
v-model.number="formDialog.data.min"
type="number"
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
:label="formDialog.fixedAmount ? 'Amount *' : 'Min *'"
></q-input>
<q-input
v-if="!formDialog.fixedAmount"
filled
dense
v-model.number="formDialog.data.max"
type="number"
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
label="Max *"
></q-input>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-checkbox
dense
v-model="formDialog.fixedAmount"
label="Fixed amount"
/>
</div>
<div class="col">
<q-select
dense
:options='["satoshis", "USD"]'
v-model="formDialog.data.currency"
:display-value="formDialog.data.currency || 'satoshis'"
label="Currency"
:hint="'Amounts will be converted at use-time to satoshis. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
@input="updateFiatRate"
/>
</div>
</div>
<q-input
filled
dense
v-model.number="formDialog.data.amount"
v-model.number="formDialog.data.comment_chars"
type="number"
label="Amount (sat) *"
label="Comment maximum characters"
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
></q-input>
<q-input
filled
dense
v-model="formDialog.data.webhook_url"
type="text"
label="Webhook URL (optional)"
hint="An URL to be called whenever this link receives a payment."
></q-input>
<q-input
filled
dense
v-model="formDialog.data.success_text"
type="text"
label="Success message (optional)"
hint="Will be shown to the user in his wallet after a successful payment."
></q-input>
<q-input
filled
dense
v-model="formDialog.data.success_url"
type="text"
label="Success URL (optional)"
hint="Will be shown as a clickable link to the user in his wallet after a successful payment, appended by the payment_hash as a query string."
></q-input>
<div class="row q-mt-lg">
<q-btn
@@ -147,9 +239,10 @@
formDialog.data.wallet == null ||
formDialog.data.description == null ||
(
formDialog.data.amount == null ||
formDialog.data.amount < 1
)"
formDialog.data.min == null ||
formDialog.data.min <= 0
)
"
type="submit"
>Create pay link</q-btn
>
@@ -173,7 +266,16 @@
</q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
<span v-if="qrCodeDialog.data.currency"
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
fiatRates[qrCodeDialog.data.currency] ?
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
/></span>
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
}}<br />
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br />
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
@@ -191,7 +293,6 @@
>Shareable link</q-btn
>
<q-btn
v-if="!qrCodeDialog.data.is_unique"
outline
color="grey"
icon="print"
@@ -205,194 +306,5 @@
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
Vue.component(VueQrcode.name, VueQrcode)
var locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
var mapPayLink = function (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: function () {
return {
checker: null,
payLinks: [],
payLinksTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'amount',
align: 'right',
label: 'Amount (sat)',
field: 'amount'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
secondMultiplier: 'seconds',
secondMultiplierOptions: ['seconds', 'minutes', 'hours'],
data: {
is_unique: false
}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
getPayLinks: function () {
var self = this
LNbits.api
.request(
'GET',
'/lnurlp/api/v1/links?all_wallets',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.payLinks = response.data.map(function (obj) {
return mapPayLink(obj)
})
})
.catch(function (error) {
clearInterval(self.checker)
LNbits.utils.notifyApiError(error)
})
},
closeFormDialog: function () {
this.formDialog.data = {
is_unique: false
}
},
openQrCodeDialog: function (linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link)
this.qrCodeDialog.show = true
},
openUpdateDialog: function (linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
this.formDialog.data = _.clone(link._data)
this.formDialog.show = true
},
sendFormData: function () {
var wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
data.wait_time =
data.wait_time *
{
seconds: 1,
minutes: 60,
hours: 3600
}[this.formDialog.secondMultiplier]
if (data.id) {
this.updatePayLink(wallet, data)
} else {
this.createPayLink(wallet, data)
}
},
updatePayLink: function (wallet, data) {
var self = this
LNbits.api
.request(
'PUT',
'/lnurlp/api/v1/links/' + data.id,
wallet.adminkey,
_.pick(data, 'description', 'amount')
)
.then(function (response) {
self.payLinks = _.reject(self.payLinks, function (obj) {
return obj.id === data.id
})
self.payLinks.push(mapPayLink(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createPayLink: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
.then(function (response) {
self.payLinks.push(mapPayLink(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deletePayLink: function (linkId) {
var self = this
var link = _.findWhere(this.payLinks, {id: linkId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/lnurlp/api/v1/links/' + linkId,
_.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey
)
.then(function (response) {
self.payLinks = _.reject(self.payLinks, function (obj) {
return obj.id === linkId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls)
}
},
created: function () {
if (this.g.user.wallets.length) {
var getPayLinks = this.getPayLinks
getPayLinks()
this.checker = setInterval(function () {
getPayLinks()
}, 20000)
}
}
})
</script>
<script src="/lnurlp/static/js/index.js"></script>
{% endblock %}
+26 -41
View File
@@ -1,22 +1,19 @@
import hashlib
from quart import g, jsonify, request, url_for
from quart import g, jsonify, request
from http import HTTPStatus
from lnurl import LnurlPayResponse, LnurlPayActionResponse
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.extensions.lnurlp import lnurlp_ext
from lnbits.extensions.lnurlp import lnurlp_ext # type: ignore
from .crud import (
create_pay_link,
get_pay_link,
get_pay_links,
update_pay_link,
increment_pay_link,
delete_pay_link,
)
from .helpers import get_fiat_rate
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
@@ -59,10 +56,24 @@ async def api_link_retrieve(link_id):
@api_validate_post_request(
schema={
"description": {"type": "string", "empty": False, "required": True},
"amount": {"type": "integer", "min": 1, "required": True},
"min": {"type": "number", "min": 0.01, "required": True},
"max": {"type": "number", "min": 0.01, "required": True},
"currency": {"type": "string", "allowed": ["USD"], "nullable": True, "required": False},
"comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800},
"webhook_url": {"type": "string", "required": False},
"success_text": {"type": "string", "required": False},
"success_url": {"type": "string", "required": False},
}
)
async def api_link_create_or_update(link_id=None):
if g.data["min"] > g.data["max"]:
return jsonify({"message": "Min is greater than max."}), HTTPStatus.BAD_REQUEST
if g.data.get("currency") == None and (
round(g.data["min"]) != g.data["min"] or round(g.data["max"]) != g.data["max"]
):
return jsonify({"message": "Must use full satoshis."}), HTTPStatus.BAD_REQUEST
if link_id:
link = get_pay_link(link_id)
@@ -95,37 +106,11 @@ async def api_link_delete(link_id):
return "", HTTPStatus.NO_CONTENT
@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
@lnurlp_ext.route("/api/v1/rate/<currency>", methods=["GET"])
async def api_check_fiat_rate(currency):
try:
rate = await get_fiat_rate(currency)
except AssertionError:
rate = None
url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True)
resp = LnurlPayResponse(
callback=url,
min_sendable=link.amount * 1000,
max_sendable=link.amount * 1000,
metadata=link.lnurlpay_metadata,
)
return jsonify(resp.dict()), 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
_, payment_request = create_invoice(
wallet_id=link.wallet,
amount=link.amount,
memo=link.description,
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
extra={"tag": "lnurlp"},
)
resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[])
return jsonify(resp.dict()), HTTPStatus.OK
return jsonify({"rate": rate}), HTTPStatus.OK
+1 -1
View File
@@ -69,7 +69,7 @@ async def api_usermanager_users_delete(user_id):
async def api_usermanager_activate_extension():
user = get_user(g.data["userid"])
if not user:
return jsonify({"error": "no such user"}), HTTPStatus.NO_CONTENT
return jsonify({"message": "no such user"}), HTTPStatus.NOT_FOUND
update_user_extension(user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"])
return jsonify({"extension": "updated"}), HTTPStatus.CREATED
@@ -118,7 +118,8 @@ new Vue({
this.qrCodeDialog.data = _.clone(link)
console.log(this.qrCodeDialog.data)
this.qrCodeDialog.data.url = window.location.host
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true
},
openUpdateDialog: function (linkId) {
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
<script type="text/javascript" src="/withdraw/static/js/index.js"></script>
<script src="/withdraw/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
@@ -287,13 +287,13 @@
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
{% raw %}
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
{% raw %}
</q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
@@ -1,7 +1,7 @@
{% extends "print.html" %} {% block page %}
<div class="row justify-center">
<div class="col-12 col-sm-8 col-lg-6 text-center" id="vue">
<div class="row">
<div class="" id="vue">
{% for page in link %}
<page size="A4" id="pdfprint">
<table style="width: 100%">
@@ -43,8 +43,11 @@
@media print {
body,
page {
margin: 0;
box-shadow: 0;
margin: 0px !important;
box-shadow: none !important;
}
.q-page {
padding: 0px !important;
}
}
</style>
@@ -54,7 +57,6 @@
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
theurl: location.protocol + '//' + location.host,
+2 -6
View File
@@ -152,13 +152,11 @@ async def api_lnurl_multi_response(unique_hash, id_unique_hash):
found = True
else:
usescsv += "," + x
print(x)
print("usescsv: " + usescsv)
if not found:
return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK
usescsv = usescsv[1:]
link = update_withdraw_link(link.id, used=link.used + 1, usescsv=usescsv)
link = update_withdraw_link(link.id, usescsv=usescsv)
return jsonify(link.lnurl_response.dict()), HTTPStatus.OK
@@ -189,9 +187,7 @@ async def api_lnurl_callback(unique_hash):
extra={"tag": "withdraw"},
)
changes = {
"open_time": link.wait_time + now,
}
changes = {"open_time": link.wait_time + now, "used": link.used + 1}
update_withdraw_link(link.id, **changes)
except ValueError as e:
+13 -2
View File
@@ -75,7 +75,18 @@ def get_js_vendored(prefer_minified: bool = False) -> List[str]:
def get_css_vendored(prefer_minified: bool = False) -> List[str]:
return get_vendored(".css", prefer_minified)
paths = get_vendored(".css", prefer_minified)
def sorter(key: str):
if "quasar@" in key:
return 1
if "vue@" in key:
return 2
if "chart.js@" in key:
return 100
return 9
return sorted(paths, key=sorter)
def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
@@ -101,7 +112,7 @@ def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
elif minified not in paths:
paths.append(path)
return paths
return sorted(paths)
def url_for_vendored(abspath: str) -> str:
+35 -40
View File
@@ -1,48 +1,46 @@
from typing import Optional, List
from typing import Optional, List, Callable
from functools import partial
from urllib.request import parse_http_list as _parse_list_header
from urllib.parse import urlparse
from werkzeug.datastructures import Headers
from quart import request
from quart import Request
from quart_trio.asgi import TrioASGIHTTPConnection
class ProxyFix:
def __init__(self, app=None, x_for: int = 1, x_proto: int = 1, x_host: int = 0, x_port: int = 0, x_prefix: int = 0):
self.app = app
self.x_for = x_for
self.x_proto = x_proto
self.x_host = x_host
self.x_port = x_port
self.x_prefix = x_prefix
class ASGIProxyFix(TrioASGIHTTPConnection):
def _create_request_from_scope(self, send: Callable) -> Request:
headers = Headers()
headers["Remote-Addr"] = (self.scope.get("client") or ["<local>"])[0]
for name, value in self.scope["headers"]:
headers.add(name.decode("latin1").title(), value.decode("latin1"))
if self.scope["http_version"] < "1.1":
headers.setdefault("Host", self.app.config["SERVER_NAME"] or "")
if app:
self.init_app(app)
path = self.scope["path"]
path = path if path[0] == "/" else urlparse(path).path
def init_app(self, app):
@app.before_request
async def before_request():
x_for = self._get_real_value(self.x_for, request.headers.get("X-Forwarded-For"))
if x_for:
request.headers["Remote-Addr"] = x_for
x_proto = self._get_real_value(1, headers.get("X-Forwarded-Proto"))
if x_proto:
self.scope["scheme"] = x_proto
x_proto = self._get_real_value(self.x_proto, request.headers.get("X-Forwarded-Proto"))
if x_proto:
request.scheme = x_proto
x_host = self._get_real_value(1, headers.get("X-Forwarded-Host"))
if x_host:
headers["host"] = x_host.lower()
x_host = self._get_real_value(self.x_host, request.headers.get("X-Forwarded-Host"))
if x_host:
request.headers["host"] = x_host.lower()
parts = x_host.split(":", 1)
# environ["SERVER_NAME"] = parts[0]
# if len(parts) == 2:
# environ["SERVER_PORT"] = parts[1]
x_port = self._get_real_value(self.x_port, request.headers.get("X-Forwarded-Port"))
if x_port:
host = request.host
if host:
parts = host.split(":", 1)
host = parts[0] if len(parts) == 2 else host
request.headers["host"] = f"{host}:{x_port}"
# environ["SERVER_PORT"] = x_port
return self.app.request_class(
self.scope["method"],
self.scope["scheme"],
path,
self.scope["query_string"],
headers,
self.scope.get("root_path", ""),
self.scope["http_version"],
max_content_length=self.app.config["MAX_CONTENT_LENGTH"],
body_timeout=self.app.config["BODY_TIMEOUT"],
send_push_promise=partial(self._send_push_promise, send),
scope=self.scope,
)
def _get_real_value(self, trusted: int, value: Optional[str]) -> Optional[str]:
"""Get the real value from a list header based on the configured
@@ -95,6 +93,3 @@ class ProxyFix:
if not is_filename or value[:2] != "\\\\":
return value.replace("\\\\", "\\").replace('\\"', '"')
return value
# host, request.root_path, subdomain, request.scheme, request.method, request.path, request.query_string.decode(),
+13
View File
@@ -1,3 +1,4 @@
import subprocess
import importlib
from environs import Env # type: ignore
@@ -27,3 +28,15 @@ DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wall
PREFER_SECURE_URLS = env.bool("LNBITS_FORCE_HTTPS", default=True)
SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0)
try:
LNBITS_COMMIT = (
subprocess.check_output(
["git", "-C", LNBITS_PATH, "rev-parse", "HEAD"],
stderr=subprocess.DEVNULL,
)
.strip()
.decode("ascii")
)
except:
LNBITS_COMMIT = "unknown"
+77 -16
View File
@@ -1,10 +1,8 @@
/* globals Vue, EventHub, axios, Quasar, _ */
/* globals crypto, moment, Vue, axios, Quasar, _ */
var LOCALE = 'en'
var EventHub = new Vue()
var LNbits = {
window.LOCALE = 'en'
window.EventHub = new Vue()
window.LNbits = {
api: {
request: function (method, url, apiKey, data) {
return axios({
@@ -16,11 +14,12 @@ var LNbits = {
data: data
})
},
createInvoice: function (wallet, amount, memo) {
createInvoice: function (wallet, amount, memo, lnurlCallback = null) {
return this.request('post', '/api/v1/payments', wallet.inkey, {
out: false,
amount: amount,
memo: memo
memo: memo,
lnurl_callback: lnurlCallback
})
},
payInvoice: function (wallet, bolt11) {
@@ -29,6 +28,30 @@ var LNbits = {
bolt11: bolt11
})
},
payLnurl: function (
wallet,
callback,
description_hash,
amount,
description = '',
comment = ''
) {
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
callback,
description_hash,
amount,
comment,
description
})
},
authLnurl: function (wallet, callback) {
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
callback
})
},
getWallet: function (wallet) {
return this.request('get', '/api/v1/wallet', wallet.inkey)
},
getPayments: function (wallet, checkPending) {
var query_param = checkPending ? '?check_pending' : ''
return this.request(
@@ -45,6 +68,19 @@ var LNbits = {
)
}
},
events: {
onInvoicePaid: function (wallet, cb) {
if (!this.pis) {
this.pis = new EventSource(
'/api/v1/payments/sse?api-key=' + wallet.inkey
)
}
this.pis.addEventListener('payment-received', ev =>
cb(JSON.parse(ev.data))
)
}
},
href: {
createWallet: function (walletName, userId) {
window.location.href =
@@ -88,7 +124,7 @@ var LNbits = {
)
obj.msat = obj.balance
obj.sat = Math.round(obj.balance / 1000)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.sat)
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
obj.url = ['/wallet?usr=', obj.user, '&wal=', obj.id].join('')
return obj
},
@@ -112,10 +148,11 @@ var LNbits = {
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.dateFrom = moment(obj.date).fromNow()
obj.msat = obj.amount
obj.sat = obj.msat / 1000
obj.tag = obj.extra.tag
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.sat)
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
obj.isIn = obj.amount > 0
obj.isOut = obj.amount < 0
obj.isPaid = obj.pending === 0
@@ -138,13 +175,13 @@ var LNbits = {
})
},
formatCurrency: function (value, currency) {
return new Intl.NumberFormat(LOCALE, {
return new Intl.NumberFormat(window.LOCALE, {
style: 'currency',
currency: currency
}).format(value)
},
formatSat: function (value) {
return new Intl.NumberFormat(LOCALE).format(value)
return new Intl.NumberFormat(window.LOCALE).format(value)
},
notifyApiError: function (error) {
var types = {
@@ -227,7 +264,7 @@ var LNbits = {
}
}
var windowMixin = {
window.windowMixin = {
data: function () {
return {
g: {
@@ -257,17 +294,17 @@ var windowMixin = {
created: function () {
this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode'))
if (window.user) {
this.g.user = Object.freeze(LNbits.map.user(window.user))
this.g.user = Object.freeze(window.LNbits.map.user(window.user))
}
if (window.wallet) {
this.g.wallet = Object.freeze(LNbits.map.wallet(window.wallet))
this.g.wallet = Object.freeze(window.LNbits.map.wallet(window.wallet))
}
if (window.extensions) {
var user = this.g.user
this.g.extensions = Object.freeze(
window.extensions
.map(function (data) {
return LNbits.map.extension(data)
return window.LNbits.map.extension(data)
})
.map(function (obj) {
if (user) {
@@ -284,3 +321,27 @@ var windowMixin = {
}
}
}
window.decryptLnurlPayAES = function (success_action, preimage) {
let keyb = new Uint8Array(
preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))
)
return crypto.subtle
.importKey('raw', keyb, {name: 'AES-CBC', length: 256}, false, ['decrypt'])
.then(key => {
let ivb = Uint8Array.from(window.atob(success_action.iv), c =>
c.charCodeAt(0)
)
let ciphertextb = Uint8Array.from(
window.atob(success_action.ciphertext),
c => c.charCodeAt(0)
)
return crypto.subtle.decrypt({name: 'AES-CBC', iv: ivb}, key, ciphertextb)
})
.then(valueb => {
let decoder = new TextDecoder('utf-8')
return decoder.decode(valueb)
})
}
+109 -1
View File
@@ -1,4 +1,4 @@
/* global Vue, LNbits, EventHub */
/* global _, Vue, moment, LNbits, EventHub, decryptLnurlPayAES */
Vue.component('lnbits-fsat', {
props: {
@@ -174,3 +174,111 @@ Vue.component('lnbits-extension-list', {
}
}
})
Vue.component('lnbits-payment-details', {
props: ['payment'],
template: `
<div class="q-py-md" style="text-align: left">
<div class="row justify-center q-mb-md">
<q-badge v-if="hasTag" color="yellow" text-color="black">
#{{ payment.tag }}
</q-badge>
</div>
<div class="row">
<div class="col-3"><b>Date</b>:</div>
<div class="col-9">{{ payment.date }} ({{ payment.dateFrom }})</div>
</div>
<div class="row">
<div class="col-3"><b>Description</b>:</div>
<div class="col-9">{{ payment.memo }}</div>
</div>
<div class="row">
<div class="col-3"><b>Amount</b>:</div>
<div class="col-9">{{ (payment.amount / 1000).toFixed(3) }} sat</div>
</div>
<div class="row">
<div class="col-3"><b>Fee</b>:</div>
<div class="col-9">{{ (payment.fee / 1000).toFixed(3) }} sat</div>
</div>
<div class="row">
<div class="col-3"><b>Payment hash</b>:</div>
<div class="col-9 text-wrap mono">{{ payment.payment_hash }}</div>
</div>
<div class="row" v-if="hasPreimage">
<div class="col-3"><b>Payment proof</b>:</div>
<div class="col-9 text-wrap mono">{{ payment.preimage }}</div>
</div>
<div class="row" v-for="entry in extras">
<div class="col-3">
<q-badge v-if="hasTag" color="purple" text-color="white">
extra
</q-badge>
<b>{{ entry.key }}</b>:
</div>
<div class="col-9 text-wrap mono">{{ entry.value }}</div>
</div>
<div class="row" v-if="hasSuccessAction">
<div class="col-3"><b>Success action</b>:</div>
<div class="col-9">
<lnbits-lnurlpay-success-action
:payment="payment"
:success_action="payment.extra.success_action"
></lnbits-lnurlpay-success-action>
</div>
</div>
</div>
`,
computed: {
hasPreimage() {
return (
this.payment.preimage &&
this.payment.preimage !==
'0000000000000000000000000000000000000000000000000000000000000000'
)
},
hasSuccessAction() {
return (
this.hasPreimage &&
this.payment.extra &&
this.payment.extra.success_action
)
},
hasTag() {
return this.payment.extra && !!this.payment.extra.tag
},
extras() {
if (!this.payment.extra) return []
let extras = _.omit(this.payment.extra, ['tag', 'success_action'])
return Object.keys(extras).map(key => ({key, value: extras[key]}))
}
}
})
Vue.component('lnbits-lnurlpay-success-action', {
props: ['payment', 'success_action'],
data() {
return {
decryptedValue: this.success_action.ciphertext
}
},
template: `
<div>
<p class="q-mb-sm">{{ success_action.message || success_action.description }}</p>
<code v-if="decryptedValue" class="text-h6 q-mt-sm q-mb-none">
{{ decryptedValue }}
</code>
<p v-else-if="success_action.url" class="text-h6 q-mt-sm q-mb-none">
<a target="_blank" style="color: inherit;" :href="success_action.url">{{ success_action.url }}</a>
</p>
</div>
`,
mounted: function () {
if (this.success_action.tag !== 'aes') return null
decryptLnurlPayAES(this.success_action, this.payment.preimage).then(
value => {
this.decryptedValue = value
}
)
}
})
+5
View File
@@ -101,3 +101,8 @@ video {
-moz-font-feature-settings: 'liga';
-moz-osx-font-smoothing: grayscale;
}
// text-wrap
.text-wrap {
word-break: break-word;
}
+98
View File
@@ -0,0 +1,98 @@
import trio # type: ignore
from http import HTTPStatus
from typing import Optional, List, Callable
from quart import Request, g
from quart_trio import QuartTrio
from werkzeug.datastructures import Headers
from lnbits.db import open_db
from lnbits.settings import WALLET
from lnbits.core.crud import get_standalone_payment
main_app: Optional[QuartTrio] = None
def grab_app_for_later(app: QuartTrio):
global main_app
main_app = app
deferred_async: List[Callable] = []
def record_async(func: Callable) -> Callable:
def recorder(state):
deferred_async.append(func)
return recorder
def run_deferred_async(nursery):
for func in deferred_async:
nursery.start_soon(func)
async def send_push_promise(a, b) -> None:
pass
async def run_on_pseudo_request(func: Callable, *args):
fk = Request(
"GET",
"http",
"/background/pseudo",
b"",
Headers([("host", "lnbits.background")]),
"",
"1.1",
send_push_promise=send_push_promise,
)
assert main_app
async def run():
async with main_app.request_context(fk):
with open_db() as g.db: # type: ignore
await func(*args)
async with trio.open_nursery() as nursery:
nursery.start_soon(run)
invoice_listeners: List[trio.MemorySendChannel] = []
def register_invoice_listener(send_chan: trio.MemorySendChannel):
"""
A method intended for extensions to call when they want to be notified about
new invoice payments incoming.
"""
invoice_listeners.append(send_chan)
async def webhook_handler():
handler = getattr(WALLET, "webhook_listener", None)
if handler:
return await handler()
return "", HTTPStatus.NO_CONTENT
internal_invoice_paid, internal_invoice_received = trio.open_memory_channel(0)
async def internal_invoice_listener():
async for checking_id in internal_invoice_received:
await run_on_pseudo_request(invoice_callback_dispatcher, checking_id)
async def invoice_listener():
async for checking_id in WALLET.paid_invoices_stream():
await run_on_pseudo_request(invoice_callback_dispatcher, checking_id)
async def invoice_callback_dispatcher(checking_id: str):
payment = get_standalone_payment(checking_id)
if payment and payment.is_in:
payment.set_pending(False)
for send_chan in invoice_listeners:
await send_chan.send(payment)
+1 -4
View File
@@ -8,10 +8,7 @@
<!---->
<link rel="stylesheet" type="text/css" href="/static/css/base.css" />
{% block styles %}{% endblock %}
<title>
{% block title %} {% if SITE_TITLE != 'LNbits' %}{{ SITE_TITLE }}{% else
%}LNbits{% endif %} {% endblock %}
</title>
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
<meta charset="utf-8" />
<meta
name="viewport"
+1 -4
View File
@@ -15,10 +15,7 @@
}
</style>
{% block styles %}{% endblock %}
<title>
{% block title %} {% if SITE_TITLE != 'LNbits' %}{{ SITE_TITLE }}{% else
%}LNbits{% endif %} {% endblock %}
</title>
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
<meta charset="utf-8" />
<meta
name="viewport"
+15 -1
View File
@@ -1,5 +1,10 @@
from abc import ABC, abstractmethod
from typing import NamedTuple, Optional
from typing import NamedTuple, Optional, AsyncGenerator
class StatusResponse(NamedTuple):
error_message: Optional[str]
balance_msat: int
class InvoiceResponse(NamedTuple):
@@ -13,6 +18,7 @@ class PaymentResponse(NamedTuple):
ok: bool
checking_id: Optional[str] = None # payment_hash, rcp_id
fee_msat: int = 0
preimage: Optional[str] = None
error_message: Optional[str] = None
@@ -25,6 +31,10 @@ class PaymentStatus(NamedTuple):
class Wallet(ABC):
@abstractmethod
def status(self) -> StatusResponse:
pass
@abstractmethod
def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
@@ -43,6 +53,10 @@ class Wallet(ABC):
def get_payment_status(self, checking_id: str) -> PaymentStatus:
pass
@abstractmethod
def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
pass
class Unsupported(Exception):
pass
+89 -16
View File
@@ -1,13 +1,16 @@
try:
from lightning import LightningRpc # type: ignore
from lightning import LightningRpc, RpcError # type: ignore
except ImportError: # pragma: nocover
LightningRpc = None
import trio # type: ignore
import random
import json
from os import getenv
from typing import Optional
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
from typing import Optional, AsyncGenerator
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
class CLightningWallet(Wallet):
@@ -15,34 +18,80 @@ class CLightningWallet(Wallet):
if LightningRpc is None: # pragma: nocover
raise ImportError("The `pylightning` library must be installed to use `CLightningWallet`.")
self.l1 = LightningRpc(getenv("CLIGHTNING_RPC"))
self.rpc = getenv("CLIGHTNING_RPC")
self.ln = LightningRpc(self.rpc)
# check description_hash support (could be provided by a plugin)
self.supports_description_hash = False
try:
answer = self.ln.help("invoicewithdescriptionhash")
if answer["help"][0]["command"].startswith(
"invoicewithdescriptionhash msatoshi label description_hash",
):
self.supports_description_hash = True
except:
pass
# check last payindex so we can listen from that point on
self.last_pay_index = 0
invoices = self.ln.listinvoices()
for inv in invoices["invoices"][::-1]:
if "pay_index" in inv:
self.last_pay_index = inv["pay_index"]
break
def status(self) -> StatusResponse:
try:
funds = self.ln.listfunds()
return StatusResponse(
None,
sum([ch["channel_sat"] * 1000 for ch in funds["channels"]]),
)
except RpcError as exc:
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
return StatusResponse(error_message, 0)
def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
) -> InvoiceResponse:
if description_hash:
raise Unsupported("description_hash")
label = "lbl{}".format(random.random())
r = self.l1.invoice(amount * 1000, label, memo, exposeprivatechannels=True)
ok, checking_id, payment_request, error_message = True, r["payment_hash"], r["bolt11"], None
return InvoiceResponse(ok, checking_id, payment_request, error_message)
msat = amount * 1000
try:
if description_hash:
if not self.supports_description_hash:
raise Unsupported("description_hash")
params = [msat, label, description_hash.hex()]
r = self.ln.call("invoicewithdescriptionhash", params)
return InvoiceResponse(True, label, r["bolt11"], "")
else:
r = self.ln.invoice(msat, label, memo, exposeprivatechannels=True)
return InvoiceResponse(True, label, r["bolt11"], "")
except RpcError as exc:
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
return InvoiceResponse(False, label, None, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = self.l1.pay(bolt11)
ok, checking_id, fee_msat, error_message = True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None
return PaymentResponse(ok, checking_id, fee_msat, error_message)
try:
r = self.ln.pay(bolt11)
except RpcError as exc:
return PaymentResponse(False, None, 0, None, str(exc))
fee_msat = r["msatoshi_sent"] - r["msatoshi"]
preimage = r["payment_preimage"]
return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = self.l1.listinvoices(checking_id)
r = self.ln.listinvoices(checking_id)
if not r["invoices"]:
return PaymentStatus(False)
if r["invoices"][0]["label"] == checking_id:
return PaymentStatus(r["pays"][0]["status"] == "paid")
return PaymentStatus(r["invoices"][0]["status"] == "paid")
raise KeyError("supplied an invalid checking_id")
def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = self.l1.listpays(payment_hash=checking_id)
r = self.ln.call("listpays", {"payment_hash": checking_id})
if not r["pays"]:
return PaymentStatus(False)
if r["pays"][0]["payment_hash"] == checking_id:
@@ -53,3 +102,27 @@ class CLightningWallet(Wallet):
return PaymentStatus(False)
return PaymentStatus(None)
raise KeyError("supplied an invalid checking_id")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
stream = await trio.open_unix_socket(self.rpc)
i = 0
while True:
call = json.dumps(
{
"method": "waitanyinvoice",
"id": 0,
"params": [self.last_pay_index],
}
)
await stream.send_all(call.encode("utf-8"))
data = await stream.receive_some()
paid = json.loads(data.decode("ascii"))
paid = self.ln.waitanyinvoice(self.last_pay_index)
self.last_pay_index = paid["pay_index"]
yield paid["label"]
i += 1
+51 -20
View File
@@ -1,8 +1,9 @@
import json
import httpx
from os import getenv
from typing import Optional, Dict
from requests import get, post
from typing import Optional, Dict, AsyncGenerator
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
class LNbitsWallet(Wallet):
@@ -10,8 +11,21 @@ class LNbitsWallet(Wallet):
def __init__(self):
self.endpoint = getenv("LNBITS_ENDPOINT")
self.auth_admin = {"X-Api-Key": getenv("LNBITS_ADMIN_KEY")}
self.auth_invoice = {"X-Api-Key": getenv("LNBITS_INVOICE_KEY")}
key = getenv("LNBITS_KEY") or getenv("LNBITS_ADMIN_KEY") or getenv("LNBITS_INVOICE_KEY")
self.key = {"X-Api-Key": key}
def status(self) -> StatusResponse:
r = httpx.get(url=f"{self.endpoint}/api/v1/wallet", headers=self.key)
try:
data = r.json()
except:
return StatusResponse(f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0)
if r.is_error:
return StatusResponse(data["message"], 0)
return StatusResponse(None, data["balance"])
def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
@@ -22,45 +36,62 @@ class LNbitsWallet(Wallet):
else:
data["memo"] = memo or ""
r = post(
r = httpx.post(
url=f"{self.endpoint}/api/v1/payments",
headers=self.auth_invoice,
headers=self.key,
json=data,
)
ok, checking_id, payment_request, error_message = r.ok, None, None, None
ok, checking_id, payment_request, error_message = not r.is_error, None, None, None
if r.ok:
if r.is_error:
error_message = r.json()["message"]
else:
data = r.json()
checking_id, payment_request = data["checking_id"], data["payment_request"]
else:
error_message = r.json()["message"]
return InvoiceResponse(ok, checking_id, payment_request, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post(url=f"{self.endpoint}/api/v1/payments", headers=self.auth_admin, json={"out": True, "bolt11": bolt11})
ok, checking_id, fee_msat, error_message = True, None, 0, None
r = httpx.post(url=f"{self.endpoint}/api/v1/payments", headers=self.key, json={"out": True, "bolt11": bolt11})
ok, checking_id, fee_msat, error_message = not r.is_error, None, 0, None
if r.ok:
if r.is_error:
error_message = r.json()["message"]
else:
data = r.json()
checking_id = data["checking_id"]
else:
error_message = r.json()["message"]
return PaymentResponse(ok, checking_id, fee_msat, error_message)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.auth_invoice)
r = httpx.get(url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key)
if not r.ok:
if r.is_error:
return PaymentStatus(None)
return PaymentStatus(r.json()["paid"])
def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.auth_invoice)
r = httpx.get(url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key)
if not r.ok:
if r.is_error:
return PaymentStatus(None)
return PaymentStatus(r.json()["paid"])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = f"{self.endpoint}/api/v1/payments/sse"
async with httpx.AsyncClient(timeout=None, headers=self.key) as client:
async with client.stream("GET", url) as r:
async for line in r.aiter_lines():
if line.startswith("data:"):
try:
data = json.loads(line[5:])
except json.decoder.JSONDecodeError:
continue
if type(data) is not list or len(data) < 9:
continue
yield data[8] # payment_hash
+158 -58
View File
@@ -1,92 +1,192 @@
try:
import lnd_grpc # type: ignore
import lndgrpc # type: ignore
from lndgrpc.common import ln # type: ignore
except ImportError: # pragma: nocover
lnd_grpc = None
lndgrpc = None
try:
import purerpc # type: ignore
except ImportError: # pragma: nocover
purerpc = None
import binascii
import base64
import hashlib
from os import getenv
from typing import Optional, Dict
from typing import Optional, Dict, AsyncGenerator
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
def get_ssl_context(cert_path: str):
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS)
context.options |= ssl.OP_NO_SSLv2
context.options |= ssl.OP_NO_SSLv3
context.options |= ssl.OP_NO_TLSv1
context.options |= ssl.OP_NO_TLSv1_1
context.options |= ssl.OP_NO_COMPRESSION
context.set_ciphers(
":".join(
[
"ECDHE+AESGCM",
"ECDHE+CHACHA20",
"DHE+AESGCM",
"DHE+CHACHA20",
"ECDH+AESGCM",
"DH+AESGCM",
"ECDH+AES",
"DH+AES",
"RSA+AESGCM",
"RSA+AES",
"!aNULL",
"!eNULL",
"!MD5",
"!DSS",
]
)
)
context.load_verify_locations(capath=cert_path)
return context
def load_macaroon(macaroon_path: str):
with open(macaroon_path, "rb") as f:
macaroon_bytes = f.read()
return macaroon_bytes.hex()
def parse_checking_id(checking_id: str) -> bytes:
return base64.b64decode(
checking_id.replace("_", "/"),
)
def stringify_checking_id(r_hash: bytes) -> str:
return (
base64.b64encode(
r_hash,
)
.decode("utf-8")
.replace("/", "_")
)
class LndWallet(Wallet):
def __init__(self):
if lnd_grpc is None: # pragma: nocover
raise ImportError("The `lnd-grpc` library must be installed to use `LndWallet`.")
if lndgrpc is None: # pragma: nocover
raise ImportError("The `lndgrpc` library must be installed to use `LndWallet`.")
if purerpc is None: # pragma: nocover
raise ImportError("The `purerpc` library must be installed to use `LndWallet`.")
endpoint = getenv("LND_GRPC_ENDPOINT")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
self.port = getenv("LND_GRPC_PORT")
self.auth_admin = getenv("LND_ADMIN_MACAROON")
self.auth_invoice = getenv("LND_INVOICE_MACAROON")
self.auth_read = getenv("LND_READ_MACAROON")
self.auth_cert = getenv("LND_CERT")
self.port = int(getenv("LND_GRPC_PORT"))
self.cert_path = getenv("LND_GRPC_CERT") or getenv("LND_CERT")
self.macaroon_path = (
getenv("LND_GRPC_MACAROON")
or getenv("LND_GRPC_ADMIN_MACAROON")
or getenv("LND_ADMIN_MACAROON")
or getenv("LND_GRPC_INVOICE_MACAROON")
or getenv("LND_INVOICE_MACAROON")
)
network = getenv("LND_GRPC_NETWORK", "mainnet")
self.rpc = lndgrpc.LNDClient(
f"{self.endpoint}:{self.port}",
cert_filepath=self.cert_path,
network=network,
macaroon_filepath=self.macaroon_path,
)
def status(self) -> StatusResponse:
try:
resp = self.rpc._ln_stub.ChannelBalance(ln.ChannelBalanceRequest())
except Exception as exc:
return StatusResponse(str(exc), 0)
return StatusResponse(None, resp.balance * 1000)
def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
) -> InvoiceResponse:
lnd_rpc = lnd_grpc.Client(
lnd_dir=None,
macaroon_path=self.auth_invoice,
tls_cert_path=self.auth_cert,
network="mainnet",
grpc_host=self.endpoint,
grpc_port=self.port,
)
params: Dict = {"value": amount, "expiry": 600, "private": True}
if description_hash:
params["description_hash"] = description_hash # as bytes directly
else:
params["memo"] = memo or ""
lndResponse = lnd_rpc.add_invoice(**params)
decoded_hash = base64.b64encode(lndResponse.r_hash).decode("utf-8").replace("/", "_")
ok, checking_id, payment_request, error_message = True, decoded_hash, str(lndResponse.payment_request), None
return InvoiceResponse(ok, checking_id, payment_request, error_message)
try:
req = ln.Invoice(**params)
resp = self.rpc._ln_stub.AddInvoice(req)
except Exception as exc:
error_message = str(exc)
return InvoiceResponse(False, None, None, error_message)
checking_id = stringify_checking_id(resp.r_hash)
payment_request = str(resp.payment_request)
return InvoiceResponse(True, checking_id, payment_request, None)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
lnd_rpc = lnd_grpc.Client(
lnd_dir=None,
macaroon_path=self.auth_admin,
tls_cert_path=self.auth_cert,
network="mainnet",
grpc_host=self.endpoint,
grpc_port=self.port,
)
resp = self.rpc.send_payment(payment_request=bolt11)
payinvoice = lnd_rpc.pay_invoice(
payment_request=bolt11,
)
if resp.payment_error:
return PaymentResponse(False, "", 0, None, resp.payment_error)
ok, checking_id, fee_msat, error_message = True, None, 0, None
if payinvoice.payment_error:
ok, error_message = False, payinvoice.payment_error
else:
checking_id = base64.b64encode(payinvoice.payment_hash).decode("utf-8").replace("/", "_")
return PaymentResponse(ok, checking_id, fee_msat, error_message)
r_hash = hashlib.sha256(resp.payment_preimage).digest()
checking_id = stringify_checking_id(r_hash)
fee_msat = resp.payment_route.total_fees_msat
preimage = resp.payment_preimage.hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r_hash = parse_checking_id(checking_id)
if len(r_hash) != 32:
raise binascii.Error
except binascii.Error:
# this may happen if we switch between backend wallets
# that use different checking_id formats
return PaymentStatus(None)
check_id = base64.b64decode(checking_id.replace("_", "/"))
print(check_id)
lnd_rpc = lnd_grpc.Client(
lnd_dir=None,
macaroon_path=self.auth_invoice,
tls_cert_path=self.auth_cert,
network="mainnet",
grpc_host=self.endpoint,
grpc_port=self.port,
)
for _response in lnd_rpc.subscribe_single_invoice(check_id):
if _response.state == 1:
return PaymentStatus(True)
resp = self.rpc.lookup_invoice(r_hash.hex())
if resp.settled:
return PaymentStatus(True)
return PaymentStatus(None)
def get_payment_status(self, checking_id: str) -> PaymentStatus:
return PaymentStatus(True)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
async with purerpc.secure_channel(
self.endpoint,
self.port,
get_ssl_context(self.cert_path),
) as channel:
client = purerpc.Client("lnrpc.Lightning", channel)
subscribe_invoices = client.get_method_stub(
"SubscribeInvoices",
purerpc.RPCSignature(
purerpc.Cardinality.UNARY_STREAM,
ln.InvoiceSubscription,
ln.Invoice,
),
)
macaroon = load_macaroon(self.macaroon_path)
async for inv in subscribe_invoices(
ln.InvoiceSubscription(),
metadata=[("macaroon", macaroon)],
):
if not inv.settled:
continue
checking_id = stringify_checking_id(inv.r_hash)
yield checking_id
print("lost connection to lnd InvoiceSubscription, please restart lnbits.")
+121 -63
View File
@@ -1,22 +1,50 @@
from os import getenv
from typing import Optional, Dict
import trio # type: ignore
import httpx
import json
import base64
from requests import get, post
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
from os import getenv
from typing import Optional, Dict, AsyncGenerator
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
class LndRestWallet(Wallet):
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
def __init__(self):
endpoint = getenv("LND_REST_ENDPOINT")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
print(self.endpoint)
self.auth_admin = {"Grpc-Metadata-macaroon": getenv("LND_REST_ADMIN_MACAROON")}
self.auth_invoice = {"Grpc-Metadata-macaroon": getenv("LND_REST_INVOICE_MACAROON")}
self.auth_read = {"Grpc-Metadata-macaroon": getenv("LND_REST_READ_MACAROON")}
self.auth_cert = getenv("LND_REST_CERT")
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
endpoint = "https://" + endpoint if not endpoint.startswith("http") else endpoint
self.endpoint = endpoint
macaroon = (
getenv("LND_REST_MACAROON")
or getenv("LND_ADMIN_MACAROON")
or getenv("LND_REST_ADMIN_MACAROON")
or getenv("LND_INVOICE_MACAROON")
or getenv("LND_REST_INVOICE_MACAROON")
)
self.auth = {"Grpc-Metadata-macaroon": macaroon}
self.cert = getenv("LND_REST_CERT")
def status(self) -> StatusResponse:
try:
r = httpx.get(
f"{self.endpoint}/v1/balance/channels",
headers=self.auth,
verify=self.cert,
)
except (httpx.ConnectError, httpx.RequestError):
return StatusResponse(f"Unable to connect to {self.endpoint}.", 0)
try:
data = r.json()
if r.is_error:
raise Exception
except Exception:
return StatusResponse(r.text[:200], 0)
return StatusResponse(None, int(data["balance"]) * 1000)
def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
@@ -30,84 +58,114 @@ class LndRestWallet(Wallet):
else:
data["memo"] = memo or ""
r = post(
r = httpx.post(
url=f"{self.endpoint}/v1/invoices",
headers=self.auth_invoice,
verify=self.auth_cert,
headers=self.auth,
verify=self.cert,
json=data,
)
ok, checking_id, payment_request, error_message = r.ok, None, None, None
if r.is_error:
error_message = r.text
try:
error_message = r.json()["error"]
except Exception:
pass
return InvoiceResponse(False, None, None, error_message)
if r.ok:
data = r.json()
payment_request = data["payment_request"]
data = r.json()
payment_request = data["payment_request"]
payment_hash = base64.b64decode(data["r_hash"]).hex()
checking_id = payment_hash
r = get(
url=f"{self.endpoint}/v1/payreq/{payment_request}",
headers=self.auth_read,
verify=self.auth_cert,
)
print(r)
if r.ok:
checking_id = r.json()["payment_hash"].replace("/", "_")
print(checking_id)
error_message = None
ok = True
return InvoiceResponse(ok, checking_id, payment_request, error_message)
return InvoiceResponse(True, checking_id, payment_request, None)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post(
r = httpx.post(
url=f"{self.endpoint}/v1/channels/transactions",
headers=self.auth_admin,
verify=self.auth_cert,
headers=self.auth,
verify=self.cert,
json={"payment_request": bolt11},
)
ok, checking_id, fee_msat, error_message = r.ok, None, 0, None
r = get(
url=f"{self.endpoint}/v1/payreq/{bolt11}",
headers=self.auth_admin,
verify=self.auth_cert,
timeout=180,
)
if r.ok:
checking_id = r.json()["payment_hash"]
else:
error_message = r.json()["error"]
if r.is_error:
error_message = r.text
try:
error_message = r.json()["error"]
except:
pass
return PaymentResponse(False, None, 0, None, error_message)
return PaymentResponse(ok, checking_id, fee_msat, error_message)
data = r.json()
payment_hash = data["payment_hash"]
checking_id = payment_hash
preimage = base64.b64decode(data["payment_preimage"]).hex()
return PaymentResponse(True, checking_id, 0, preimage, None)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
checking_id = checking_id.replace("_", "/")
print(checking_id)
r = get(
r = httpx.get(
url=f"{self.endpoint}/v1/invoice/{checking_id}",
headers=self.auth_invoice,
verify=self.auth_cert,
headers=self.auth,
verify=self.cert,
)
print(r.json()["settled"])
if not r or r.json()["settled"] == False:
if r.is_error or not r.json().get("settled"):
# this must also work when checking_id is not a hex recognizable by lnd
# it will return an error and no "settled" attribute on the object
return PaymentStatus(None)
return PaymentStatus(r.json()["settled"])
return PaymentStatus(True)
def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = get(
r = httpx.get(
url=f"{self.endpoint}/v1/payments",
headers=self.auth_admin,
verify=self.auth_cert,
params={"include_incomplete": "True", "max_payments": "20"},
headers=self.auth,
verify=self.cert,
params={"max_payments": "20", "reversed": True},
)
if not r.ok:
if r.is_error:
return PaymentStatus(None)
payments = [p for p in r.json()["payments"] if p["payment_hash"] == checking_id]
print(checking_id)
payment = payments[0] if payments else None
# check payment.status: https://api.lightning.community/rest/index.html?python#peersynctype
# check payment.status:
# https://api.lightning.community/rest/index.html?python#peersynctype
statuses = {"UNKNOWN": None, "IN_FLIGHT": None, "SUCCEEDED": True, "FAILED": False}
return PaymentStatus(statuses[payment["status"]])
# for some reason our checking_ids are in base64 but the payment hashes
# returned here are in hex, lnd is weird
checking_id = base64.b64decode(checking_id).hex()
for p in r.json()["payments"]:
if p["payment_hash"] == checking_id:
return PaymentStatus(statuses[p["status"]])
return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = self.endpoint + "/v1/invoices/subscribe"
while True:
try:
async with httpx.AsyncClient(
timeout=None,
headers=self.auth,
verify=self.cert,
) as client:
async with client.stream("GET", url) as r:
async for line in r.aiter_lines():
try:
inv = json.loads(line)["result"]
if not inv["settled"]:
continue
except:
continue
payment_hash = base64.b64decode(inv["r_hash"]).hex()
yield payment_hash
except (OSError, httpx.ConnectError, httpx.ReadError):
pass
print("lost connection to lnd invoices stream, retrying in 5 seconds")
await trio.sleep(5)
+89 -22
View File
@@ -1,23 +1,46 @@
import json
import trio # type: ignore
import httpx
from os import getenv
from typing import Optional, Dict
from requests import get, post
from http import HTTPStatus
from typing import Optional, Dict, AsyncGenerator
from quart import request
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
class LNPayWallet(Wallet):
"""https://docs.lnpay.co/"""
def __init__(self):
endpoint = getenv("LNPAY_API_ENDPOINT")
endpoint = getenv("LNPAY_API_ENDPOINT", "https://lnpay.co/v1")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
self.auth_admin = getenv("LNPAY_ADMIN_KEY")
self.auth_invoice = getenv("LNPAY_INVOICE_KEY")
self.auth_read = getenv("LNPAY_READ_KEY")
self.auth_api = {"X-Api-Key": getenv("LNPAY_API_KEY")}
self.wallet_key = getenv("LNPAY_WALLET_KEY") or getenv("LNPAY_ADMIN_KEY")
self.auth = {"X-Api-Key": getenv("LNPAY_API_KEY")}
def status(self) -> StatusResponse:
url = f"{self.endpoint}/wallet/{self.wallet_key}"
try:
r = httpx.get(url, headers=self.auth, timeout=60)
except (httpx.ConnectError, httpx.RequestError):
return StatusResponse(f"Unable to connect to '{url}'", 0)
if r.is_error:
return StatusResponse(r.text[:250], 0)
data = r.json()
if data["statusType"]["name"] != "active":
return StatusResponse(
f"Wallet {data['user_label']} (data['id']) not active, but {data['statusType']['name']}", 0
)
return StatusResponse(None, data["balance"] * 1000)
def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
data: Dict = {"num_satoshis": f"{amount}"}
if description_hash:
@@ -25,12 +48,18 @@ class LNPayWallet(Wallet):
else:
data["memo"] = memo or ""
r = post(
url=f"{self.endpoint}/user/wallet/{self.auth_invoice}/invoice",
headers=self.auth_api,
r = httpx.post(
f"{self.endpoint}/wallet/{self.wallet_key}/invoice",
headers=self.auth,
json=data,
timeout=60,
)
ok, checking_id, payment_request, error_message = (
r.status_code == 201,
None,
None,
r.text,
)
ok, checking_id, payment_request, error_message = r.status_code == 201, None, None, r.text
if ok:
data = r.json()
@@ -39,26 +68,64 @@ class LNPayWallet(Wallet):
return InvoiceResponse(ok, checking_id, payment_request, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post(
url=f"{self.endpoint}/user/wallet/{self.auth_admin}/withdraw",
headers=self.auth_api,
r = httpx.post(
f"{self.endpoint}/wallet/{self.wallet_key}/withdraw",
headers=self.auth,
json={"payment_request": bolt11},
timeout=180,
)
ok, checking_id, fee_msat, error_message = r.status_code == 201, None, 0, None
if ok:
checking_id = r.json()["lnTx"]["id"]
try:
data = r.json()
except:
return PaymentResponse(False, None, 0, None, f"Got invalid JSON: {r.text[:200]}")
return PaymentResponse(ok, checking_id, fee_msat, error_message)
if r.is_error:
return PaymentResponse(False, None, 0, None, data["message"])
checking_id = data["lnTx"]["id"]
fee_msat = 0
preimage = data["lnTx"]["payment_preimage"]
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
return self.get_payment_status(checking_id)
def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/user/lntx/{checking_id}", headers=self.auth_api)
r = httpx.get(
url=f"{self.endpoint}/lntx/{checking_id}?fields=settled",
headers=self.auth,
)
if not r.ok:
if r.is_error:
return PaymentStatus(None)
statuses = {0: None, 1: True, -1: False}
return PaymentStatus(statuses[r.json()["settled"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.send, receive = trio.open_memory_channel(0)
async for value in receive:
yield value
async def webhook_listener(self):
text: str = await request.get_data()
try:
data = json.loads(text)
except json.decoder.JSONDecodeError:
print(f"got something wrong on lnpay webhook endpoint: {text[:200]}")
data = None
if type(data) is not dict or "event" not in data or data["event"].get("name") != "wallet_receive":
return "", HTTPStatus.NO_CONTENT
lntx_id = data["data"]["wtx"]["lnTx"]["id"]
async with httpx.AsyncClient() as client:
r = await client.get(
f"{self.endpoint}/lntx/{lntx_id}?fields=settled",
headers=self.auth,
)
data = r.json()
if data["settled"]:
await self.send.send(lntx_id)
return "", HTTPStatus.NO_CONTENT
+95 -39
View File
@@ -1,8 +1,10 @@
import trio # type: ignore
import json
import httpx
from os import getenv
from typing import Optional, Dict
from requests import post
from typing import Optional, Dict, AsyncGenerator
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
class LntxbotWallet(Wallet):
@@ -11,8 +13,25 @@ class LntxbotWallet(Wallet):
def __init__(self):
endpoint = getenv("LNTXBOT_API_ENDPOINT")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
self.auth_admin = {"Authorization": f"Basic {getenv('LNTXBOT_ADMIN_KEY')}"}
self.auth_invoice = {"Authorization": f"Basic {getenv('LNTXBOT_INVOICE_KEY')}"}
key = getenv("LNTXBOT_KEY") or getenv("LNTXBOT_ADMIN_KEY") or getenv("LNTXBOT_INVOICE_KEY")
self.auth = {"Authorization": f"Basic {key}"}
def status(self) -> StatusResponse:
r = httpx.get(
f"{self.endpoint}/balance",
headers=self.auth,
timeout=40,
)
try:
data = r.json()
except:
return StatusResponse(f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0)
if data.get("error"):
return StatusResponse(data["message"], 0)
return StatusResponse(None, data["BTC"]["AvailableBalance"] * 1000)
def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
@@ -23,44 +42,59 @@ class LntxbotWallet(Wallet):
else:
data["memo"] = memo or ""
r = post(
url=f"{self.endpoint}/addinvoice",
headers=self.auth_invoice,
r = httpx.post(
f"{self.endpoint}/addinvoice",
headers=self.auth,
json=data,
timeout=40,
)
ok, checking_id, payment_request, error_message = r.ok, None, None, None
if r.ok:
data = r.json()
checking_id, payment_request = data["payment_hash"], data["pay_req"]
if "error" in data and data["error"]:
ok = False
if r.is_error:
try:
data = r.json()
error_message = data["message"]
except:
error_message = r.text
pass
return InvoiceResponse(ok, checking_id, payment_request, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post(url=f"{self.endpoint}/payinvoice", headers=self.auth_admin, json={"invoice": bolt11})
ok, checking_id, fee_msat, error_message = r.ok, None, 0, None
if r.ok:
data = r.json()
if "payment_hash" in data:
checking_id, fee_msat = data["decoded"]["payment_hash"], data["fee_msat"]
elif "error" in data and data["error"]:
ok, error_message = False, data["message"]
return PaymentResponse(ok, checking_id, fee_msat, error_message)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = post(url=f"{self.endpoint}/invoicestatus/{checking_id}?wait=false", headers=self.auth_invoice)
if not r.ok or "error" in r.json():
return PaymentStatus(None)
return InvoiceResponse(False, None, None, error_message)
data = r.json()
return InvoiceResponse(True, data["payment_hash"], data["pay_req"], None)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = httpx.post(
f"{self.endpoint}/payinvoice",
headers=self.auth,
json={"invoice": bolt11},
timeout=40,
)
if r.is_error:
try:
data = r.json()
error_message = data["message"]
except:
error_message = r.text
pass
return PaymentResponse(False, None, 0, None, error_message)
data = r.json()
checking_id = data["payment_hash"]
fee_msat = data["fee_msat"]
preimage = data["payment_preimage"]
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = httpx.post(
f"{self.endpoint}/invoicestatus/{checking_id}?wait=false",
headers=self.auth,
)
data = r.json()
if r.is_error or "error" in data:
return PaymentStatus(None)
if "preimage" not in data:
return PaymentStatus(False)
@@ -68,10 +102,32 @@ class LntxbotWallet(Wallet):
return PaymentStatus(True)
def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = post(url=f"{self.endpoint}/paymentstatus/{checking_id}", headers=self.auth_invoice)
r = httpx.post(
url=f"{self.endpoint}/paymentstatus/{checking_id}",
headers=self.auth,
)
if not r.ok or "error" in r.json():
data = r.json()
if r.is_error or "error" in data:
return PaymentStatus(None)
statuses = {"complete": True, "failed": False, "pending": None, "unknown": None}
return PaymentStatus(statuses[r.json().get("status", "unknown")])
return PaymentStatus(statuses[data.get("status", "unknown")])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = f"{self.endpoint}/payments/stream"
while True:
try:
async with httpx.AsyncClient(timeout=None, headers=self.auth) as client:
async with client.stream("GET", url) as r:
async for line in r.aiter_lines():
if line.startswith("data:"):
data = json.loads(line[5:])
if "payment_hash" in data and data.get("msatoshi") > 0:
yield data["payment_hash"]
except (OSError, httpx.ReadError):
pass
print("lost connection to lntxbot /payments/stream, retrying in 5 seconds")
await trio.sleep(5)
+78 -27
View File
@@ -1,8 +1,13 @@
import json
import trio # type: ignore
import hmac
import httpx
from http import HTTPStatus
from os import getenv
from typing import Optional
from requests import get, post
from typing import Optional, AsyncGenerator
from quart import request, url_for
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
class OpenNodeWallet(Wallet):
@@ -11,8 +16,25 @@ class OpenNodeWallet(Wallet):
def __init__(self):
endpoint = getenv("OPENNODE_API_ENDPOINT")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
self.auth_admin = {"Authorization": getenv("OPENNODE_ADMIN_KEY")}
self.auth_invoice = {"Authorization": getenv("OPENNODE_INVOICE_KEY")}
key = getenv("OPENNODE_KEY") or getenv("OPENNODE_ADMIN_KEY") or getenv("OPENNODE_INVOICE_KEY")
self.auth = {"Authorization": key}
def status(self) -> StatusResponse:
try:
r = httpx.get(
f"{self.endpoint}/v1/account/balance",
headers=self.auth,
timeout=40,
)
except (httpx.ConnectError, httpx.RequestError):
return StatusResponse(f"Unable to connect to '{self.endpoint}'", 0)
data = r.json()["data"]
if r.is_error:
return StatusResponse(data["message"], 0)
return StatusResponse(None, data["balance"]["BTC"] / 100_000_000_000)
def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
@@ -20,47 +42,76 @@ class OpenNodeWallet(Wallet):
if description_hash:
raise Unsupported("description_hash")
r = post(
url=f"{self.endpoint}/v1/charges",
headers=self.auth_invoice,
json={"amount": f"{amount}", "description": memo}, # , "private": True},
r = httpx.post(
f"{self.endpoint}/v1/charges",
headers=self.auth,
json={
"amount": amount,
"description": memo or "",
"callback_url": url_for("webhook_listener", _external=True),
},
timeout=40,
)
ok, checking_id, payment_request, error_message = r.ok, None, None, None
if r.ok:
data = r.json()["data"]
checking_id, payment_request = data["id"], data["lightning_invoice"]["payreq"]
else:
if r.is_error:
error_message = r.json()["message"]
return InvoiceResponse(False, None, None, error_message)
return InvoiceResponse(ok, checking_id, payment_request, error_message)
data = r.json()["data"]
checking_id = data["id"]
payment_request = data["lightning_invoice"]["payreq"]
return InvoiceResponse(True, checking_id, payment_request, None)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post(url=f"{self.endpoint}/v2/withdrawals", headers=self.auth_admin, json={"type": "ln", "address": bolt11})
ok, checking_id, fee_msat, error_message = r.ok, None, 0, None
r = httpx.post(
f"{self.endpoint}/v2/withdrawals",
headers=self.auth,
json={"type": "ln", "address": bolt11},
timeout=180,
)
if r.ok:
data = r.json()["data"]
checking_id, fee_msat = data["id"], data["fee"] * 1000
else:
if r.is_error:
error_message = r.json()["message"]
return PaymentResponse(False, None, 0, None, error_message)
return PaymentResponse(ok, checking_id, fee_msat, error_message)
data = r.json()["data"]
checking_id = data["id"]
fee_msat = data["fee"] * 1000
return PaymentResponse(True, checking_id, fee_msat, None, None)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/v1/charge/{checking_id}", headers=self.auth_invoice)
if not r.ok:
r = httpx.get(f"{self.endpoint}/v1/charge/{checking_id}", headers=self.auth)
if r.is_error:
return PaymentStatus(None)
statuses = {"processing": None, "paid": True, "unpaid": False}
return PaymentStatus(statuses[r.json()["data"]["status"]])
def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/v1/withdrawal/{checking_id}", headers=self.auth_admin)
r = httpx.get(f"{self.endpoint}/v1/withdrawal/{checking_id}", headers=self.auth)
if not r.ok:
if r.is_error:
return PaymentStatus(None)
statuses = {"initial": None, "pending": None, "confirmed": True, "error": False, "failed": False}
return PaymentStatus(statuses[r.json()["data"]["status"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.send, receive = trio.open_memory_channel(0)
async for value in receive:
yield value
async def webhook_listener(self):
data = await request.form
if "status" not in data or data["status"] != "paid":
return "", HTTPStatus.NO_CONTENT
charge_id = data["id"]
x = hmac.new(self.auth["Authorization"].encode("ascii"), digestmod="sha256")
x.update(charge_id.encode("ascii"))
if x.hexdigest() != data["hashed_order"]:
print("invalid webhook, not from opennode")
return "", HTTPStatus.NO_CONTENT
await self.send.send(charge_id)
return "", HTTPStatus.NO_CONTENT
+56 -10
View File
@@ -1,9 +1,11 @@
import trio # type: ignore
import random
import requests
import json
import httpx
from os import getenv
from typing import Optional
from typing import Optional, AsyncGenerator
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
class SparkError(Exception):
@@ -16,7 +18,7 @@ class UnknownError(Exception):
class SparkWallet(Wallet):
def __init__(self):
self.url = getenv("SPARK_URL")
self.url = getenv("SPARK_URL").replace("/rpc", "")
self.token = getenv("SPARK_TOKEN")
def __getattr__(self, key):
@@ -27,18 +29,43 @@ class SparkWallet(Wallet):
params = args
elif kwargs:
params = kwargs
else:
params = {}
r = requests.post(self.url, headers={"X-Access": self.token}, json={"method": key, "params": params})
r = httpx.post(
self.url + "/rpc",
headers={"X-Access": self.token},
json={"method": key, "params": params},
timeout=40,
)
try:
data = r.json()
except:
raise UnknownError(r.text)
if not r.ok:
if r.is_error:
if r.status_code == 401:
raise SparkError("Access key invalid!")
raise SparkError(data["message"])
return data
return call
def status(self) -> StatusResponse:
try:
funds = self.listfunds()
except (httpx.ConnectError, httpx.RequestError):
return StatusResponse("Couldn't connect to Spark server", 0)
except (SparkError, UnknownError) as e:
return StatusResponse(str(e), 0)
return StatusResponse(
None,
sum([ch["channel_sat"] * 1000 for ch in funds["channels"]]),
)
def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
) -> InvoiceResponse:
@@ -65,11 +92,12 @@ class SparkWallet(Wallet):
def pay_invoice(self, bolt11: str) -> PaymentResponse:
try:
r = self.pay(bolt11)
ok, checking_id, fee_msat, error_message = True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None
except (SparkError, UnknownError) as e:
ok, checking_id, fee_msat, error_message = False, None, None, str(e)
except (SparkError, UnknownError) as exc:
return PaymentResponse(False, None, 0, None, str(exc))
return PaymentResponse(ok, checking_id, fee_msat, error_message)
fee_msat = r["msatoshi_sent"] - r["msatoshi"]
preimage = r["payment_preimage"]
return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = self.listinvoices(label=checking_id)
@@ -91,3 +119,21 @@ class SparkWallet(Wallet):
return PaymentStatus(False)
return PaymentStatus(None)
raise KeyError("supplied an invalid checking_id")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = self.url + "/stream?access-key=" + self.token
while True:
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("GET", url) as r:
async for line in r.aiter_lines():
if line.startswith("data:"):
data = json.loads(line[5:])
if "pay_index" in data and data.get("status") == "paid":
yield data["label"]
except (OSError, httpx.ReadError):
pass
print("lost connection to spark /stream, retrying in 5 seconds")
await trio.sleep(5)
+11 -2
View File
@@ -1,6 +1,6 @@
from typing import Optional
from typing import Optional, AsyncGenerator
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
class VoidWallet(Wallet):
@@ -9,6 +9,12 @@ class VoidWallet(Wallet):
) -> InvoiceResponse:
raise Unsupported("")
def status(self) -> StatusResponse:
return StatusResponse(
"This backend does nothing, it is here just as a placeholder, you must configure an actual backend before being able to do anything useful with LNbits.",
0,
)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
raise Unsupported("")
@@ -17,3 +23,6 @@ class VoidWallet(Wallet):
def get_payment_status(self, checking_id: str) -> PaymentStatus:
raise Unsupported("")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
yield ""
+2
View File
@@ -0,0 +1,2 @@
[pytest]
trio_mode = true
+15 -8
View File
@@ -1,25 +1,29 @@
aiofiles==0.5.0
async-generator==1.10
attrs==20.2.0
bech32==1.2.0
bitstring==3.1.7
blinker==1.4
brotli==1.0.9
cerberus==1.3.2
certifi==2020.6.20
chardet==3.0.4
click==7.1.2
ecdsa==0.16.0
environs==8.0.0
h11==0.10.0
h2==3.2.0
hpack==3.0.0
hypercorn==0.10.2
hyperframe==5.2.0
h11==0.11.0
h2==4.0.0
hpack==4.0.0
httpcore==0.12.0
httpx==0.16.1
hypercorn==0.11.1
hyperframe==6.0.0
idna==2.10
itsdangerous==1.1.0
jinja2==2.11.2
lnurl==0.3.5
markupsafe==1.1.1
marshmallow==3.8.0
outcome==1.0.1
priority==1.3.0
pydantic==1.6.1
pyscss==1.3.7
@@ -27,12 +31,15 @@ python-dotenv==0.14.0
quart==0.13.1
quart-compress==0.2.1
quart-cors==0.3.0
requests==2.24.0
quart-trio==0.5.1
rfc3986==1.4.0
secure==0.2.1
shortuuid==1.0.1
six==1.15.0
sniffio==1.1.0
sortedcontainers==2.2.2
toml==0.10.1
trio==0.17.0
typing-extensions==3.7.4.3
urllib3==1.25.10
werkzeug==1.0.1
wsproto==0.15.0
-1
View File
@@ -4,7 +4,6 @@ from lnbits.app import create_app
@pytest.fixture
@pytest.mark.asyncio
async def client():
app = create_app()
app.config["TESTING"] = True
-1
View File
@@ -1,7 +1,6 @@
import pytest
@pytest.mark.asyncio
async def test_homepage(client):
r = await client.get("/")
assert b"Add a new wallet" in await r.get_data()