Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f877dde2b0 | ||
|
|
594e28eab7 | ||
|
|
a67b93108b | ||
|
|
a198842f50 | ||
|
|
22b31a91c7 | ||
|
|
54881d777e | ||
|
|
b7e337b0ee | ||
|
|
b794f8302d | ||
|
|
805000cd06 | ||
|
|
bb94dc6526 | ||
|
|
d4e30356c7 | ||
|
|
4456dd6f59 | ||
|
|
eaec3480e6 | ||
|
|
8f1ae1646e | ||
|
|
d75c69cd10 | ||
|
|
2e12c0ba3c | ||
|
|
8947358c80 | ||
|
|
091f9b74f3 | ||
|
|
cf3c5d83fe | ||
|
|
2863653261 | ||
|
|
2552fd8fc9 | ||
|
|
a773584f6f | ||
|
|
85ec111ff0 | ||
|
|
52619ecc0f | ||
|
|
11e04e4314 | ||
|
|
32d75f7a60 | ||
|
|
435f5405c6 | ||
|
|
a46ca45de0 | ||
|
|
9cb58833ad | ||
|
|
c81e61e4fe | ||
|
|
bdbb58b2b0 | ||
|
|
be7d36214a | ||
|
|
f02ec67f35 | ||
|
|
d528d4e09e | ||
|
|
ec4b558638 | ||
|
|
0dc60d4795 | ||
|
|
3a56aaa3ad | ||
|
|
e1501e8ed6 | ||
|
|
1529ebb689 | ||
|
|
bcdc065cc0 | ||
|
|
cf0bd7ece8 | ||
|
|
c6bd060d19 | ||
|
|
8d135489ab | ||
|
|
69063190ab | ||
|
|
bc2207ba27 | ||
|
|
3cd15c40fc | ||
|
|
7a5159f293 | ||
|
|
fa8713de17 | ||
|
|
b1f30390b0 | ||
|
|
f2ea8a0c74 | ||
|
|
b5a07c7ae7 | ||
|
|
d5d85d16e6 | ||
|
|
2894944b1e | ||
|
|
31820e041c | ||
|
|
1d00060419 | ||
|
|
7f41d73a84 | ||
|
|
f638a1088e | ||
|
|
ea3418c21d | ||
|
|
f6bcff01f4 | ||
|
|
33fa540744 | ||
|
|
6f72f1fe6d | ||
|
|
76633fb71f | ||
|
|
9185342c72 | ||
|
|
fc6e49b821 | ||
|
|
35f8e3334c | ||
|
|
0b1f3c22e0 | ||
|
|
24c8201920 | ||
|
|
9a16dfa960 | ||
|
|
c5352c0309 | ||
|
|
95e8573ff8 | ||
|
|
4e68a0e7e6 | ||
|
|
16541d40c6 | ||
|
|
bb965e4119 | ||
|
|
5ead82f57c | ||
|
|
1eac618a25 | ||
|
|
e1744caec5 | ||
|
|
39c331a7b9 | ||
|
|
6e6ce093f0 | ||
|
|
1ce9936561 | ||
|
|
e408febf97 | ||
|
|
c3ded859df | ||
|
|
f5b8ed8fc6 | ||
|
|
9994e61615 | ||
|
|
e74cf33f90 | ||
|
|
b3c69ad49c | ||
|
|
90c640b659 | ||
|
|
28e6f40bea | ||
|
|
bbe8d05af4 | ||
|
|
2c92205703 | ||
|
|
74117ffc57 | ||
|
|
04222f1f01 | ||
|
|
e2f6c20e3b | ||
|
|
08b2ce5c05 | ||
|
|
5971eefe44 | ||
|
|
a11b967ffc | ||
|
|
f6e6bf73d3 | ||
|
|
f2619a4bbc | ||
|
|
88b8826bfd | ||
|
|
211ac0391b | ||
|
|
e0b8470d40 | ||
|
|
2dfae9ecc1 | ||
|
|
3ab29e6e50 | ||
|
|
f60cebb072 | ||
|
|
d8da88aefa | ||
|
|
49baa07141 |
+28
-22
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
web: hypercorn --bind 0.0.0.0:5000 lnbits:app
|
||||
web: hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dokku": {}
|
||||
"dokku": {
|
||||
"predeploy": "quart migrate && quart assets"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
@@ -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": <string>, "name": <string>, "balance":
|
||||
<int>}</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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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."}),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -101,3 +101,8 @@ video {
|
||||
-moz-font-feature-settings: 'liga';
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// text-wrap
|
||||
.text-wrap {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
trio_mode = true
|
||||
+15
-8
@@ -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
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
Reference in New Issue
Block a user