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_ENV=development
|
||||||
QUART_DEBUG=true
|
QUART_DEBUG=true
|
||||||
|
|
||||||
@@ -8,43 +8,49 @@ PORT=5000
|
|||||||
LNBITS_SITE_TITLE=LNbits
|
LNBITS_SITE_TITLE=LNbits
|
||||||
LNBITS_ALLOWED_USERS=""
|
LNBITS_ALLOWED_USERS=""
|
||||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
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_DISABLED_EXTENSIONS="amilk"
|
||||||
LNBITS_FORCE_HTTPS=true
|
LNBITS_FORCE_HTTPS=true
|
||||||
LNBITS_SERVICE_FEE="0.0"
|
LNBITS_SERVICE_FEE="0.0"
|
||||||
|
|
||||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC), LndRestWallet, CLightningWallet, LnbitsWallet
|
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC),
|
||||||
LNBITS_BACKEND_WALLET_CLASS=LntxbotWallet
|
# 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"
|
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||||
|
|
||||||
LNBITS_ENDPOINT=127.0.0.1:5000
|
# LnbitsWallet
|
||||||
LNBITS_INVOICE_MACAROON=LNBITS_INVOICE_MACAROON
|
LNBITS_ENDPOINT=http://127.0.0.1:5000
|
||||||
LNBITS_ADMIN_MACAROON=LNBITS_ADMIN_MACAROON
|
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||||
|
|
||||||
|
# LndWallet
|
||||||
LND_GRPC_ENDPOINT=127.0.0.1
|
LND_GRPC_ENDPOINT=127.0.0.1
|
||||||
LND_GRPC_PORT=11009
|
LND_GRPC_PORT=11009
|
||||||
LND_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
LND_GRPC_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_GRPC_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_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_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||||
LND_REST_ADMIN_MACAROON="HEXSTRING"
|
LND_REST_MACAROON="HEXSTRING"
|
||||||
LND_REST_INVOICE_MACAROON="HEXSTRING"
|
|
||||||
LND_REST_READ_MACAROON="HEXSTRING"
|
|
||||||
|
|
||||||
|
# LNPayWallet
|
||||||
LNPAY_API_ENDPOINT=https://lnpay.co/v1/
|
LNPAY_API_ENDPOINT=https://lnpay.co/v1/
|
||||||
LNPAY_API_KEY=LNPAY_API_KEY
|
LNPAY_API_KEY=LNPAY_API_KEY
|
||||||
LNPAY_ADMIN_KEY=LNPAY_ADMIN_KEY
|
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||||
LNPAY_INVOICE_KEY=LNPAY_INVOICE_KEY
|
|
||||||
LNPAY_READ_KEY=LNPAY_READ_KEY
|
|
||||||
|
|
||||||
|
# LntxbotWallet
|
||||||
LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/
|
LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/
|
||||||
LNTXBOT_ADMIN_KEY=LNTXBOT_ADMIN_KEY
|
LNTXBOT_KEY=LNTXBOT_ADMIN_KEY
|
||||||
LNTXBOT_INVOICE_KEY=LNTXBOT_INVOICE_KEY
|
|
||||||
|
|
||||||
|
# OpenNodeWallet
|
||||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||||
OPENNODE_ADMIN_KEY=OPENNODE_ADMIN_KEY
|
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||||
OPENNODE_INVOICE_KEY=OPENNODE_INVOICE_KEY
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ FROM python:3.7-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt /app/
|
COPY requirements.txt /app/
|
||||||
RUN pip install --no-cache-dir -q -r requirements.txt
|
RUN pip install --no-cache-dir -q -r requirements.txt
|
||||||
RUN pip install --no-cache-dir -q hypercorn
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|||||||
@@ -13,17 +13,20 @@ ecdsa = "*"
|
|||||||
environs = "*"
|
environs = "*"
|
||||||
lnurl = "*"
|
lnurl = "*"
|
||||||
pyscss = "*"
|
pyscss = "*"
|
||||||
requests = "*"
|
|
||||||
shortuuid = "*"
|
shortuuid = "*"
|
||||||
quart = "*"
|
quart = "*"
|
||||||
quart-cors = "*"
|
quart-cors = "*"
|
||||||
quart-compress = "*"
|
quart-compress = "*"
|
||||||
secure = "*"
|
secure = "*"
|
||||||
typing-extensions = "*"
|
typing-extensions = "*"
|
||||||
|
httpx = "*"
|
||||||
|
quart-trio = "*"
|
||||||
|
trio = "*"
|
||||||
|
hypercorn = {extras = ["trio"], version = "*"}
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
black = "==20.8b1"
|
black = "==20.8b1"
|
||||||
pytest = "*"
|
pytest = "*"
|
||||||
pytest-cov = "*"
|
pytest-cov = "*"
|
||||||
pytest-asyncio = "*"
|
|
||||||
mypy = "==0.761"
|
mypy = "==0.761"
|
||||||
|
pytest-trio = "*"
|
||||||
|
|||||||
Generated
+186
-79
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "775e2ea809508c83df4696bba3a38a03e288cba22f0a7f562120230f40351ab9"
|
"sha256": "193ef930ac0906127fd292b2fc7ba5b4d786227b6795d182dad9b57448fca75e"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -23,6 +23,22 @@
|
|||||||
],
|
],
|
||||||
"version": "==0.5.0"
|
"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": {
|
"bech32": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899",
|
"sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899",
|
||||||
@@ -90,13 +106,6 @@
|
|||||||
],
|
],
|
||||||
"version": "==2020.6.20"
|
"version": "==2020.6.20"
|
||||||
},
|
},
|
||||||
"chardet": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
|
||||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
|
||||||
],
|
|
||||||
"version": "==3.0.4"
|
|
||||||
},
|
|
||||||
"click": {
|
"click": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||||
@@ -123,46 +132,67 @@
|
|||||||
},
|
},
|
||||||
"h11": {
|
"h11": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:311dc5478c2568cc07262e0381cdfc5b9c6ba19775905736c87e81ae6662b9fd",
|
"sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab",
|
||||||
"sha256:9eecfbafc980976dbff26a01dd3487644dd5d00f8038584451fc64a660f7c502"
|
"sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87"
|
||||||
],
|
],
|
||||||
"version": "==0.10.0"
|
"version": "==0.11.0"
|
||||||
},
|
},
|
||||||
"h2": {
|
"h2": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5",
|
"sha256:ac9e293a1990b339d5d71b19c5fe630e3dd4d768c620d1730d355485323f1b25",
|
||||||
"sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"
|
"sha256:bb7ac7099dd67a857ed52c815a6192b6b1f5ba6b516237fc24a085341340593d"
|
||||||
],
|
],
|
||||||
"version": "==3.2.0"
|
"markers": "python_full_version >= '3.6.1'",
|
||||||
|
"version": "==4.0.0"
|
||||||
},
|
},
|
||||||
"hpack": {
|
"hpack": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89",
|
"sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c",
|
||||||
"sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"
|
"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": {
|
"hypercorn": {
|
||||||
"hashes": [
|
"extras": [
|
||||||
"sha256:19f32e7267225c8108ad585b2c5deddf1fe75950797a0e87a682a3a00ef1af95",
|
"trio"
|
||||||
"sha256:809d77f3bf9fa0794a598d8dfa0f8d889e7e1c2f927581cd33068803169dc474"
|
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.7'",
|
"hashes": [
|
||||||
"version": "==0.10.2"
|
"sha256:81c69dd84a87b8e8b3ebf06ef5dd92836a8238f0ac65ded3d86befb8ba9acfeb",
|
||||||
|
"sha256:e3f317d6d64d15ce589f49e4f5057947259fa35332d169e62cb060e9997189e4"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.11.1"
|
||||||
},
|
},
|
||||||
"hyperframe": {
|
"hyperframe": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40",
|
"sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1",
|
||||||
"sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"
|
"sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34"
|
||||||
],
|
],
|
||||||
"version": "==5.2.0"
|
"markers": "python_full_version >= '3.6.1'",
|
||||||
|
"version": "==6.0.0"
|
||||||
},
|
},
|
||||||
"idna": {
|
"idna": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
|
||||||
"version": "==2.10"
|
"version": "==2.10"
|
||||||
},
|
},
|
||||||
"itsdangerous": {
|
"itsdangerous": {
|
||||||
@@ -235,6 +265,14 @@
|
|||||||
"markers": "python_version >= '3.5'",
|
"markers": "python_version >= '3.5'",
|
||||||
"version": "==3.8.0"
|
"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": {
|
"priority": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe",
|
"sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe",
|
||||||
@@ -303,13 +341,23 @@
|
|||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==0.3.0"
|
"version": "==0.3.0"
|
||||||
},
|
},
|
||||||
"requests": {
|
"quart-trio": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
|
"sha256:00f3b20f8d82ce7e81ead61db4efba38ed7653c7e28199defded46b663ab2595",
|
||||||
"sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
|
"sha256:dafc8f0440d4b70fa60d24122a161d2373894d2bfa9f713d9f1df1fd508f0834"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.24.0"
|
"version": "==0.5.1"
|
||||||
|
},
|
||||||
|
"rfc3986": {
|
||||||
|
"extras": [
|
||||||
|
"idna2008"
|
||||||
|
],
|
||||||
|
"hashes": [
|
||||||
|
"sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d",
|
||||||
|
"sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"
|
||||||
|
],
|
||||||
|
"version": "==1.4.0"
|
||||||
},
|
},
|
||||||
"secure": {
|
"secure": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -335,6 +383,21 @@
|
|||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
"version": "==1.15.0"
|
"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": {
|
"toml": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
|
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
|
||||||
@@ -342,6 +405,14 @@
|
|||||||
],
|
],
|
||||||
"version": "==0.10.1"
|
"version": "==0.10.1"
|
||||||
},
|
},
|
||||||
|
"trio": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
|
||||||
|
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.17.0"
|
||||||
|
},
|
||||||
"typing-extensions": {
|
"typing-extensions": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
|
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
|
||||||
@@ -351,14 +422,6 @@
|
|||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.7.4.3"
|
"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": {
|
"werkzeug": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",
|
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",
|
||||||
@@ -384,6 +447,14 @@
|
|||||||
],
|
],
|
||||||
"version": "==1.4.4"
|
"version": "==1.4.4"
|
||||||
},
|
},
|
||||||
|
"async-generator": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
|
||||||
|
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.5'",
|
||||||
|
"version": "==1.10"
|
||||||
|
},
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
|
"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'",
|
"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"
|
"version": "==5.3"
|
||||||
},
|
},
|
||||||
|
"idna": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||||
|
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||||
|
],
|
||||||
|
"version": "==2.10"
|
||||||
|
},
|
||||||
"iniconfig": {
|
"iniconfig": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
|
"sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
|
||||||
@@ -454,14 +532,6 @@
|
|||||||
],
|
],
|
||||||
"version": "==1.0.1"
|
"version": "==1.0.1"
|
||||||
},
|
},
|
||||||
"more-itertools": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
|
|
||||||
"sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.5'",
|
|
||||||
"version": "==8.5.0"
|
|
||||||
},
|
|
||||||
"mypy": {
|
"mypy": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
|
"sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
|
||||||
@@ -489,6 +559,14 @@
|
|||||||
],
|
],
|
||||||
"version": "==0.4.3"
|
"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": {
|
"packaging": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
|
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
|
||||||
@@ -530,19 +608,11 @@
|
|||||||
},
|
},
|
||||||
"pytest": {
|
"pytest": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0e37f61339c4578776e090c3b8f6b16ce4db333889d65d0efb305243ec544b40",
|
"sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9",
|
||||||
"sha256:c8f57c2a30983f469bf03e68cdfa74dc474ce56b8f280ddcb080dfd91df01043"
|
"sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==6.0.2"
|
"version": "==6.1.1"
|
||||||
},
|
|
||||||
"pytest-asyncio": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d",
|
|
||||||
"sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==0.14.0"
|
|
||||||
},
|
},
|
||||||
"pytest-cov": {
|
"pytest-cov": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -552,31 +622,45 @@
|
|||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.10.1"
|
"version": "==2.10.1"
|
||||||
},
|
},
|
||||||
|
"pytest-trio": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:3f48cc1df66d279d705af38ad38d1639c2e2380ddffcdc3a45bb81758de61f03",
|
||||||
|
"sha256:9bf0a490fd177a33617e8709242293fae47934de2b51f8209eb2c0545b6ca8fe"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.6.0"
|
||||||
|
},
|
||||||
"regex": {
|
"regex": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204",
|
"sha256:088afc8c63e7bd187a3c70a94b9e50ab3f17e1d3f52a32750b5b77dbe99ef5ef",
|
||||||
"sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162",
|
"sha256:1fe0a41437bbd06063aa184c34804efa886bcc128222e9916310c92cd54c3b4c",
|
||||||
"sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f",
|
"sha256:3d20024a70b97b4f9546696cbf2fd30bae5f42229fbddf8661261b1eaff0deb7",
|
||||||
"sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb",
|
"sha256:41bb65f54bba392643557e617316d0d899ed5b4946dccee1cb6696152b29844b",
|
||||||
"sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6",
|
"sha256:4318d56bccfe7d43e5addb272406ade7a2274da4b70eb15922a071c58ab0108c",
|
||||||
"sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7",
|
"sha256:4707f3695b34335afdfb09be3802c87fa0bc27030471dbc082f815f23688bc63",
|
||||||
"sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88",
|
"sha256:49f23ebd5ac073765ecbcf046edc10d63dcab2f4ae2bce160982cb30df0c0302",
|
||||||
"sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99",
|
"sha256:5533a959a1748a5c042a6da71fe9267a908e21eded7a4f373efd23a2cbdb0ecc",
|
||||||
"sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644",
|
"sha256:5d892a4f1c999834eaa3c32bc9e8b976c5825116cde553928c4c8e7e48ebda67",
|
||||||
"sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a",
|
"sha256:5f18875ac23d9aa2f060838e8b79093e8bb2313dbaaa9f54c6d8e52a5df097be",
|
||||||
"sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840",
|
"sha256:60b0e9e6dc45683e569ec37c55ac20c582973841927a85f2d8a7d20ee80216ab",
|
||||||
"sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067",
|
"sha256:816064fc915796ea1f26966163f6845de5af78923dfcecf6551e095f00983650",
|
||||||
"sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd",
|
"sha256:84cada8effefe9a9f53f9b0d2ba9b7b6f5edf8d2155f9fdbe34616e06ececf81",
|
||||||
"sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4",
|
"sha256:84e9407db1b2eb368b7ecc283121b5e592c9aaedbe8c78b1a2f1102eb2e21d19",
|
||||||
"sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e",
|
"sha256:8d69cef61fa50c8133382e61fd97439de1ae623fe943578e477e76a9d9471637",
|
||||||
"sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89",
|
"sha256:9a02d0ae31d35e1ec12a4ea4d4cca990800f66a917d0fb997b20fbc13f5321fc",
|
||||||
"sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e",
|
"sha256:9bc13e0d20b97ffb07821aa3e113f9998e84994fe4d159ffa3d3a9d1b805043b",
|
||||||
"sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc",
|
"sha256:a6f32aea4260dfe0e55dc9733ea162ea38f0ea86aa7d0f77b15beac5bf7b369d",
|
||||||
"sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf",
|
"sha256:ae91972f8ac958039920ef6e8769277c084971a142ce2b660691793ae44aae6b",
|
||||||
"sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341",
|
"sha256:c570f6fa14b9c4c8a4924aaad354652366577b4f98213cf76305067144f7b100",
|
||||||
"sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"
|
"sha256:c9443124c67b1515e4fe0bb0aa18df640965e1030f468a2a5dc2589b26d130ad",
|
||||||
|
"sha256:d23a18037313714fb3bb5a94434d3151ee4300bae631894b1ac08111abeaa4a3",
|
||||||
|
"sha256:eaf548d117b6737df379fdd53bdde4f08870e66d7ea653e230477f071f861121",
|
||||||
|
"sha256:ebbe29186a3d9b0c591e71b7393f1ae08c83cb2d8e517d2a822b8f7ec99dfd8b",
|
||||||
|
"sha256:eda4771e0ace7f67f58bc5b560e27fb20f32a148cbc993b0c3835970935c2707",
|
||||||
|
"sha256:f1b3afc574a3db3b25c89161059d857bd4909a1269b0b3cb3c904677c8c4a3f7",
|
||||||
|
"sha256:f2388013e68e750eaa16ccbea62d4130180c26abb1d8e5d584b9baf69672b30f"
|
||||||
],
|
],
|
||||||
"version": "==2020.7.14"
|
"version": "==2020.9.27"
|
||||||
},
|
},
|
||||||
"six": {
|
"six": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -586,6 +670,21 @@
|
|||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
"version": "==1.15.0"
|
"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": {
|
"toml": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
|
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
|
||||||
@@ -593,6 +692,14 @@
|
|||||||
],
|
],
|
||||||
"version": "==0.10.1"
|
"version": "==0.10.1"
|
||||||
},
|
},
|
||||||
|
"trio": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
|
||||||
|
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.17.0"
|
||||||
|
},
|
||||||
"typed-ast": {
|
"typed-ast": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
|
"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
|
* Fallback wallet for the LNURL scheme
|
||||||
* Instant wallet for LN demonstrations
|
* 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.
|
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.
|
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
|
## LNbits as an account system
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dokku": {}
|
"dokku": {
|
||||||
|
"predeploy": "quart migrate && quart assets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ nav_order: 1
|
|||||||
Installation
|
Installation
|
||||||
============
|
============
|
||||||
|
|
||||||
Download this repo and install the dependencies.
|
Download the latest stable release https://github.com/lnbits/lnbits/releases
|
||||||
|
|
||||||
|
|
||||||
Application dependencies
|
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
|
$ 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.
|
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.
|
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:
|
Download this repo and install the dependencies:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ git clone https://github.com/lnbits/lnbits.git
|
git clone https://github.com/lnbits/lnbits.git
|
||||||
$ python3 -m venv .venv
|
cd lnbits/
|
||||||
$ source ./.venv/bin/activate
|
python3 -m venv venv
|
||||||
(.venv) $ pip install -r requirements.txt
|
./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
|
Then you can run restart it and it will be using the new settings.
|
||||||
(.venv) $ python -m lnbits
|
|
||||||
```
|
|
||||||
|
|
||||||
You might also need to install additional packages, depending on the [backend wallet](./wallets.md) you use.
|
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.
|
||||||
E.g. when you want to use LND you have to run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
(.venv) $ pip install lnd-grpc
|
|
||||||
```
|
|
||||||
|
|||||||
+14
-20
@@ -29,56 +29,50 @@ Using this wallet requires the installation of the `pylightning` Python package.
|
|||||||
|
|
||||||
### LND (gRPC)
|
### 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**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
|
||||||
- `LND_GRPC_ENDPOINT`: ip_address
|
- `LND_GRPC_ENDPOINT`: ip_address
|
||||||
- `LND_GRPC_PORT`: port
|
- `LND_GRPC_PORT`: port
|
||||||
- `LND_CERT`: /file/path/tls.cert
|
- `LND_GRPC_CERT`: /file/path/tls.cert
|
||||||
- `LND_ADMIN_MACAROON`: /file/path/admin.macaroon
|
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
|
||||||
- `LND_INVOICE_MACAROON`: /file/path/invoice.macaroon
|
|
||||||
- `LND_READ_MACAROON`: /file/path/read.macaroon
|
|
||||||
|
|
||||||
|
|
||||||
### LND (REST)
|
### LND (REST)
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||||
- `LND_REST_ENDPOINT`: ip_address
|
- `LND_REST_ENDPOINT`: ip_address
|
||||||
- `LND_CERT`: /file/path/tls.cert
|
- `LND_REST_CERT`: /file/path/tls.cert
|
||||||
- `LND_ADMIN_MACAROON`: /file/path/admin.macaroon
|
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
|
||||||
- `LND_INVOICE_MACAROON`: /file/path/invoice.macaroon
|
|
||||||
- `LND_READ_MACAROON`: /file/path/read.macaroon
|
|
||||||
|
|
||||||
|
|
||||||
### LNbits
|
### LNbits
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
||||||
- `LNBITS_ENDPOINT`: e.g. https://lnbits.com
|
- `LNBITS_ENDPOINT`: e.g. https://lnbits.com
|
||||||
- `LNBITS_ADMIN_KEY`: apiKey
|
- `LNBITS_KEY`: lnbitsAdminKey
|
||||||
- `LNBITS_INVOICE_KEY`: apiKey
|
|
||||||
|
|
||||||
|
|
||||||
### LNPay
|
### 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**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
|
||||||
- `LNPAY_API_ENDPOINT`: https://lnpay.co/v1/
|
- `LNPAY_API_ENDPOINT`: https://lnpay.co/v1/
|
||||||
- `LNPAY_API_KEY`: apiKey
|
- `LNPAY_API_KEY`: sak_apiKey
|
||||||
- `LNPAY_ADMIN_KEY`: apiKey
|
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||||
- `LNPAY_INVOICE_KEY`: apiKey
|
|
||||||
- `LNPAY_READ_KEY`: apiKey
|
|
||||||
|
|
||||||
|
|
||||||
### lntxbot
|
### lntxbot
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LntxbotWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **LntxbotWallet**
|
||||||
- `LNTXBOT_API_ENDPOINT`: https://lntxbot.bigsun.xyz/
|
- `LNTXBOT_API_ENDPOINT`: https://lntxbot.bigsun.xyz/
|
||||||
- `LNTXBOT_ADMIN_KEY`: apiKey
|
- `LNTXBOT_KEY`: lntxbotAdminApiKey
|
||||||
- `LNTXBOT_INVOICE_KEY`: apiKey
|
|
||||||
|
|
||||||
|
|
||||||
### OpenNode
|
### 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**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
|
||||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||||
- `OPENNODE_ADMIN_KEY`: apiKey
|
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||||
- `OPENNODE_INVOICE_KEY`: apiKey
|
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
from .app import create_app
|
from .app import create_app
|
||||||
from .commands import migrate_databases, transpile_scss, bundle_vendored
|
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()
|
migrate_databases()
|
||||||
transpile_scss()
|
transpile_scss()
|
||||||
bundle_vendored()
|
bundle_vendored()
|
||||||
|
|
||||||
app = create_app()
|
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"])
|
app.run(host=app.config["HOST"], port=app.config["PORT"])
|
||||||
|
|||||||
+62
-14
@@ -1,57 +1,88 @@
|
|||||||
import importlib
|
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_cors import cors # type: ignore
|
||||||
from quart_compress import Compress # type: ignore
|
from quart_compress import Compress # type: ignore
|
||||||
from secure import SecureHeaders # 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 .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 .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)
|
secure_headers = SecureHeaders(hsts=False)
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_object="lnbits.settings") -> Quart:
|
def create_app(config_object="lnbits.settings") -> QuartTrio:
|
||||||
"""Create application factory.
|
"""Create application factory.
|
||||||
:param config_object: The configuration object to use.
|
: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.config.from_object(config_object)
|
||||||
|
app.asgi_http_class = ASGIProxyFix
|
||||||
|
|
||||||
cors(app)
|
cors(app)
|
||||||
Compress(app)
|
Compress(app)
|
||||||
ProxyFix(app, x_proto=1, x_host=1)
|
|
||||||
|
|
||||||
|
check_funding_source(app)
|
||||||
register_assets(app)
|
register_assets(app)
|
||||||
register_blueprints(app)
|
register_blueprints(app)
|
||||||
register_filters(app)
|
register_filters(app)
|
||||||
register_commands(app)
|
register_commands(app)
|
||||||
register_request_hooks(app)
|
register_request_hooks(app)
|
||||||
|
register_async_tasks(app)
|
||||||
|
grab_app_for_later(app)
|
||||||
|
|
||||||
return 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."""
|
"""Register Flask blueprints / LNbits extensions."""
|
||||||
app.register_blueprint(core_app)
|
app.register_blueprint(core_app)
|
||||||
|
|
||||||
for ext in get_valid_extensions():
|
for ext in get_valid_extensions():
|
||||||
try:
|
try:
|
||||||
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
|
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:
|
except Exception:
|
||||||
raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.")
|
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."""
|
"""Register Click commands."""
|
||||||
app.cli.add_command(db_migrate)
|
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."""
|
"""Serve each vendored asset separately or a bundle."""
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
@@ -64,18 +95,19 @@ def register_assets(app: Quart):
|
|||||||
g.VENDORED_CSS = ["/static/bundle.css"]
|
g.VENDORED_CSS = ["/static/bundle.css"]
|
||||||
|
|
||||||
|
|
||||||
def register_filters(app: Quart):
|
def register_filters(app: QuartTrio):
|
||||||
"""Jinja filters."""
|
"""Jinja filters."""
|
||||||
app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"]
|
app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"]
|
||||||
app.jinja_env.globals["EXTENSIONS"] = get_valid_extensions()
|
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"""
|
"""Open the core db for each request so everything happens in a big transaction"""
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def before_request():
|
async def before_request():
|
||||||
g.db = open_db()
|
g.db = open_db()
|
||||||
|
g.nursery = app.nursery
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
async def set_secure_headers(response):
|
async def set_secure_headers(response):
|
||||||
@@ -84,4 +116,20 @@ def register_request_hooks(app: Quart):
|
|||||||
|
|
||||||
@app.teardown_request
|
@app.teardown_request
|
||||||
async def after_request(exc):
|
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 click
|
||||||
import importlib
|
import importlib
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from scss.compiler import compile_string # type: ignore
|
|
||||||
|
|
||||||
from .core import migrations as core_migrations
|
from .core import migrations as core_migrations
|
||||||
from .db import open_db, open_ext_db
|
from .db import open_db, open_ext_db
|
||||||
from .helpers import get_valid_extensions, get_css_vendored, get_js_vendored, url_for_vendored
|
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():
|
def transpile_scss():
|
||||||
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
|
with warnings.catch_warnings():
|
||||||
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
|
warnings.simplefilter("ignore")
|
||||||
css.write(compile_string(scss.read()))
|
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():
|
def bundle_vendored():
|
||||||
|
|||||||
@@ -8,4 +8,8 @@ core_app: Blueprint = Blueprint(
|
|||||||
|
|
||||||
from .views.api import * # noqa
|
from .views.api import * # noqa
|
||||||
from .views.generic 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]:
|
def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]:
|
||||||
row = g.db.fetchone(
|
row = g.db.fetchone(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
from ecdsa import SECP256k1, SigningKey # type: ignore
|
||||||
from typing import List, NamedTuple, Optional, Dict
|
from typing import List, NamedTuple, Optional, Dict
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
|
|
||||||
|
from lnbits.settings import WALLET
|
||||||
|
|
||||||
|
|
||||||
class User(NamedTuple):
|
class User(NamedTuple):
|
||||||
id: str
|
id: str
|
||||||
@@ -31,6 +36,16 @@ class Wallet(NamedTuple):
|
|||||||
def balance(self) -> int:
|
def balance(self) -> int:
|
||||||
return self.balance_msat // 1000
|
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"]:
|
def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
||||||
from .crud import get_wallet_payment
|
from .crud import get_wallet_payment
|
||||||
|
|
||||||
@@ -68,6 +83,7 @@ class Payment(NamedTuple):
|
|||||||
preimage: str
|
preimage: str
|
||||||
payment_hash: str
|
payment_hash: str
|
||||||
extra: Dict
|
extra: Dict
|
||||||
|
wallet_id: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: Row):
|
def from_row(cls, row: Row):
|
||||||
@@ -82,6 +98,7 @@ class Payment(NamedTuple):
|
|||||||
fee=row["fee"],
|
fee=row["fee"],
|
||||||
memo=row["memo"],
|
memo=row["memo"],
|
||||||
time=row["time"],
|
time=row["time"],
|
||||||
|
wallet_id=row["wallet"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -113,6 +130,17 @@ class Payment(NamedTuple):
|
|||||||
|
|
||||||
update_payment_status(self.checking_id, pending)
|
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:
|
def delete(self) -> None:
|
||||||
from .crud import delete_payment
|
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 typing import Optional, Tuple, Dict
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
from quart import g
|
from quart import g
|
||||||
|
from lnurl import LnurlErrorResponse, LnurlWithdrawResponse # type: ignore
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from typing import TypedDict # type: ignore
|
from typing import TypedDict # type: ignore
|
||||||
@@ -9,7 +16,7 @@ except ImportError: # pragma: nocover
|
|||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
from lnbits.settings import WALLET
|
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
|
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(
|
def create_invoice(
|
||||||
*,
|
*,
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
amount: int,
|
amount: int, # in satoshis
|
||||||
memo: str,
|
memo: str,
|
||||||
description_hash: Optional[bytes] = None,
|
description_hash: Optional[bytes] = None,
|
||||||
extra: Optional[Dict] = None,
|
extra: Optional[Dict] = None,
|
||||||
@@ -49,7 +56,12 @@ def create_invoice(
|
|||||||
|
|
||||||
|
|
||||||
def pay_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:
|
) -> str:
|
||||||
temp_id = f"temp_{urlsafe_short_hash()}"
|
temp_id = f"temp_{urlsafe_short_hash()}"
|
||||||
internal_id = f"internal_{urlsafe_short_hash()}"
|
internal_id = f"internal_{urlsafe_short_hash()}"
|
||||||
@@ -77,13 +89,13 @@ def pay_invoice(
|
|||||||
payment_request=payment_request,
|
payment_request=payment_request,
|
||||||
payment_hash=invoice.payment_hash,
|
payment_hash=invoice.payment_hash,
|
||||||
amount=-invoice.amount_msat,
|
amount=-invoice.amount_msat,
|
||||||
memo=invoice.description or "",
|
memo=description or invoice.description or "",
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
# check_internal() returns the checking_id of the invoice we're waiting for
|
# check_internal() returns the checking_id of the invoice we're waiting for
|
||||||
internal = check_internal(invoice.payment_hash)
|
internal_checking_id = check_internal(invoice.payment_hash)
|
||||||
if internal:
|
if internal_checking_id:
|
||||||
# create a new payment from this wallet
|
# create a new payment from this wallet
|
||||||
create_payment(checking_id=internal_id, fee=0, pending=False, **payment_kwargs)
|
create_payment(checking_id=internal_id, fee=0, pending=False, **payment_kwargs)
|
||||||
else:
|
else:
|
||||||
@@ -94,31 +106,130 @@ def pay_invoice(
|
|||||||
|
|
||||||
# do the balance check
|
# do the balance check
|
||||||
wallet = get_wallet(wallet_id)
|
wallet = get_wallet(wallet_id)
|
||||||
assert wallet, "invalid wallet id"
|
assert wallet
|
||||||
if wallet.balance_msat < 0:
|
if wallet.balance_msat < 0:
|
||||||
g.db.rollback()
|
g.db.rollback()
|
||||||
raise PermissionError("Insufficient balance.")
|
raise PermissionError("Insufficient balance.")
|
||||||
else:
|
else:
|
||||||
g.db.commit()
|
g.db.commit()
|
||||||
|
|
||||||
if internal:
|
if internal_checking_id:
|
||||||
# mark the invoice from the other side as not pending anymore
|
# 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
|
# so the other side only has access to his new money when we are sure
|
||||||
# the payer has enough to deduct from
|
# 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:
|
else:
|
||||||
# actually pay the external invoice
|
# actually pay the external invoice
|
||||||
ok, checking_id, fee_msat, error_message = WALLET.pay_invoice(payment_request)
|
payment: PaymentResponse = WALLET.pay_invoice(payment_request)
|
||||||
if ok:
|
if payment.ok and payment.checking_id:
|
||||||
create_payment(checking_id=checking_id, fee=fee_msat, **payment_kwargs)
|
create_payment(
|
||||||
|
checking_id=payment.checking_id,
|
||||||
|
fee=payment.fee_msat,
|
||||||
|
preimage=payment.preimage,
|
||||||
|
**payment_kwargs,
|
||||||
|
)
|
||||||
delete_payment(temp_id)
|
delete_payment(temp_id)
|
||||||
else:
|
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()
|
g.db.commit()
|
||||||
return invoice.payment_hash
|
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:
|
def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus:
|
||||||
payment = get_wallet_payment(wallet_id, payment_hash)
|
payment = get_wallet_payment(wallet_id, payment_hash)
|
||||||
if not payment:
|
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.component(VueQrcode.name, VueQrcode)
|
||||||
Vue.use(VueQrcodeReader)
|
Vue.use(VueQrcodeReader)
|
||||||
@@ -14,10 +14,8 @@ function generateChart(canvas, payments) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_.each(
|
_.each(
|
||||||
payments.slice(0).sort(function (a, b) {
|
payments.filter(p => !p.pending).sort((a, b) => a.time - b.time),
|
||||||
return a.time - b.time
|
tx => {
|
||||||
}),
|
|
||||||
function (tx) {
|
|
||||||
txs.push({
|
txs.push({
|
||||||
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
|
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
|
||||||
sat: tx.sat
|
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(
|
var income = _.reduce(
|
||||||
value,
|
value,
|
||||||
function (memo, tx) {
|
(memo, tx) => (tx.sat >= 0 ? memo + tx.sat : memo),
|
||||||
return tx.sat >= 0 ? memo + tx.sat : memo
|
|
||||||
},
|
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
var outcome = _.reduce(
|
var outcome = _.reduce(
|
||||||
value,
|
value,
|
||||||
function (memo, tx) {
|
(memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo),
|
||||||
return tx.sat < 0 ? memo + Math.abs(tx.sat) : memo
|
|
||||||
},
|
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
n = n + income - outcome
|
n = n + income - outcome
|
||||||
@@ -122,22 +116,30 @@ new Vue({
|
|||||||
show: false,
|
show: false,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
paymentReq: null,
|
paymentReq: null,
|
||||||
|
paymentHash: null,
|
||||||
|
minMax: [0, 2100000000000000],
|
||||||
|
lnurl: null,
|
||||||
data: {
|
data: {
|
||||||
amount: null,
|
amount: null,
|
||||||
memo: ''
|
memo: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
send: {
|
parse: {
|
||||||
show: false,
|
show: false,
|
||||||
invoice: null,
|
invoice: null,
|
||||||
|
lnurlpay: null,
|
||||||
|
lnurlauth: null,
|
||||||
data: {
|
data: {
|
||||||
bolt11: ''
|
request: '',
|
||||||
|
amount: 0,
|
||||||
|
comment: ''
|
||||||
|
},
|
||||||
|
paymentChecker: null,
|
||||||
|
camera: {
|
||||||
|
show: false,
|
||||||
|
camera: 'auto'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sendCamera: {
|
|
||||||
show: false,
|
|
||||||
camera: 'auto'
|
|
||||||
},
|
|
||||||
payments: [],
|
payments: [],
|
||||||
paymentsTable: {
|
paymentsTable: {
|
||||||
columns: [
|
columns: [
|
||||||
@@ -160,6 +162,12 @@ new Vue({
|
|||||||
label: 'Amount (sat)',
|
label: 'Amount (sat)',
|
||||||
field: 'sat',
|
field: 'sat',
|
||||||
sortable: true
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fee',
|
||||||
|
align: 'right',
|
||||||
|
label: 'Fee (msat)',
|
||||||
|
field: 'fee'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
@@ -173,32 +181,23 @@ new Vue({
|
|||||||
disclaimerDialog: {
|
disclaimerDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
location: window.location
|
location: window.location
|
||||||
}
|
},
|
||||||
|
balance: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
formattedBalance: function () {
|
||||||
|
return LNbits.utils.formatSat(this.balance || this.g.wallet.sat)
|
||||||
|
},
|
||||||
filteredPayments: function () {
|
filteredPayments: function () {
|
||||||
var q = this.paymentsTable.filter
|
var q = this.paymentsTable.filter
|
||||||
if (!q || q === '') return this.payments
|
if (!q || q === '') return this.payments
|
||||||
|
|
||||||
return LNbits.utils.search(this.payments, q)
|
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 () {
|
canPay: function () {
|
||||||
if (!this.send.invoice) return false
|
if (!this.parse.invoice) return false
|
||||||
return this.send.invoice.sat <= this.balance
|
return this.parse.invoice.sat <= this.balance
|
||||||
},
|
},
|
||||||
pendingPaymentsExist: function () {
|
pendingPaymentsExist: function () {
|
||||||
return this.payments
|
return this.payments
|
||||||
@@ -206,105 +205,201 @@ new Vue({
|
|||||||
: false
|
: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
filters: {
|
||||||
|
msatoshiFormat: function (value) {
|
||||||
|
return LNbits.utils.formatSat(value / 1000)
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
closeCamera: function () {
|
closeCamera: function () {
|
||||||
this.sendCamera.show = false
|
this.parse.camera.show = false
|
||||||
},
|
},
|
||||||
showCamera: function () {
|
showCamera: function () {
|
||||||
this.sendCamera.show = true
|
this.parse.camera.show = true
|
||||||
},
|
},
|
||||||
showChart: function () {
|
showChart: function () {
|
||||||
this.paymentsChart.show = true
|
this.paymentsChart.show = true
|
||||||
this.$nextTick(function () {
|
this.$nextTick(() => {
|
||||||
generateChart(this.$refs.canvas, this.payments)
|
generateChart(this.$refs.canvas, this.payments)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
showReceiveDialog: function () {
|
showReceiveDialog: function () {
|
||||||
this.receive = {
|
this.receive.show = true
|
||||||
show: true,
|
this.receive.status = 'pending'
|
||||||
status: 'pending',
|
this.receive.paymentReq = null
|
||||||
paymentReq: null,
|
this.receive.paymentHash = null
|
||||||
data: {
|
this.receive.data.amount = null
|
||||||
amount: null,
|
this.receive.data.memo = null
|
||||||
memo: ''
|
this.receive.paymentChecker = null
|
||||||
},
|
this.receive.minMax = [0, 2100000000000000]
|
||||||
paymentChecker: null
|
this.receive.lnurl = null
|
||||||
}
|
|
||||||
},
|
},
|
||||||
showSendDialog: function () {
|
showParseDialog: function () {
|
||||||
this.send = {
|
this.parse.show = true
|
||||||
show: true,
|
this.parse.invoice = null
|
||||||
invoice: null,
|
this.parse.lnurlpay = null
|
||||||
data: {
|
this.parse.lnurlauth = null
|
||||||
bolt11: ''
|
this.parse.data.request = ''
|
||||||
},
|
this.parse.data.comment = ''
|
||||||
paymentChecker: null
|
this.parse.data.paymentChecker = null
|
||||||
}
|
this.parse.camera.show = false
|
||||||
},
|
},
|
||||||
closeReceiveDialog: function () {
|
closeReceiveDialog: function () {
|
||||||
var checker = this.receive.paymentChecker
|
setTimeout(() => {
|
||||||
setTimeout(function () {
|
clearInterval(this.receive.paymentChecker)
|
||||||
clearInterval(checker)
|
|
||||||
}, 10000)
|
}, 10000)
|
||||||
},
|
},
|
||||||
closeSendDialog: function () {
|
closeParseDialog: function () {
|
||||||
this.sendCamera.show = false
|
setTimeout(() => {
|
||||||
var checker = this.send.paymentChecker
|
clearInterval(this.parse.paymentChecker)
|
||||||
setTimeout(function () {
|
}, 10000)
|
||||||
clearInterval(checker)
|
},
|
||||||
}, 1000)
|
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 () {
|
createInvoice: function () {
|
||||||
var self = this
|
|
||||||
this.receive.status = 'loading'
|
this.receive.status = 'loading'
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.createInvoice(
|
.createInvoice(
|
||||||
this.g.wallet,
|
this.g.wallet,
|
||||||
this.receive.data.amount,
|
this.receive.data.amount,
|
||||||
this.receive.data.memo
|
this.receive.data.memo,
|
||||||
|
this.receive.lnurl && this.receive.lnurl.callback
|
||||||
)
|
)
|
||||||
.then(function (response) {
|
.then(response => {
|
||||||
self.receive.status = 'success'
|
this.receive.status = 'success'
|
||||||
self.receive.paymentReq = response.data.payment_request
|
this.receive.paymentReq = response.data.payment_request
|
||||||
|
this.receive.paymentHash = response.data.payment_hash
|
||||||
|
|
||||||
self.receive.paymentChecker = setInterval(function () {
|
if (response.data.lnurl_response !== null) {
|
||||||
LNbits.api
|
if (response.data.lnurl_response === false) {
|
||||||
.getPayment(self.g.wallet, response.data.payment_hash)
|
response.data.lnurl_response = `Unable to connect`
|
||||||
.then(function (response) {
|
}
|
||||||
if (response.data.paid) {
|
|
||||||
self.fetchPayments()
|
if (typeof response.data.lnurl_response === 'string') {
|
||||||
self.receive.show = false
|
// failure
|
||||||
clearInterval(self.receive.paymentChecker)
|
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) {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(err)
|
||||||
self.receive.status = 'pending'
|
this.receive.status = 'pending'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
decodeQR: function (res) {
|
decodeQR: function (res) {
|
||||||
this.send.data.bolt11 = res
|
this.parse.data.request = res
|
||||||
this.decodeInvoice()
|
this.decodeRequest()
|
||||||
this.sendCamera.show = false
|
this.parse.camera.show = false
|
||||||
},
|
},
|
||||||
decodeInvoice: function () {
|
decodeRequest: function () {
|
||||||
if (this.send.data.bolt11.startsWith('lightning:')) {
|
this.parse.show = true
|
||||||
this.send.data.bolt11 = this.send.data.bolt11.slice(10)
|
|
||||||
|
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
|
let invoice
|
||||||
try {
|
try {
|
||||||
invoice = decode(this.send.data.bolt11)
|
invoice = decode(this.parse.data.request)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: error + '.',
|
message: error + '.',
|
||||||
caption: '400 BAD REQUEST',
|
caption: '400 BAD REQUEST'
|
||||||
icon: null
|
|
||||||
})
|
})
|
||||||
|
this.parse.show = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +409,7 @@ new Vue({
|
|||||||
fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000)
|
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 (_.isObject(tag) && _.has(tag, 'description')) {
|
||||||
if (tag.description === 'payment_hash') {
|
if (tag.description === 'payment_hash') {
|
||||||
cleanInvoice.hash = tag.value
|
cleanInvoice.hash = tag.value
|
||||||
@@ -333,68 +428,191 @@ new Vue({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.send.invoice = Object.freeze(cleanInvoice)
|
this.parse.invoice = Object.freeze(cleanInvoice)
|
||||||
},
|
},
|
||||||
payInvoice: function () {
|
payInvoice: function () {
|
||||||
var self = this
|
|
||||||
|
|
||||||
let dismissPaymentMsg = this.$q.notify({
|
let dismissPaymentMsg = this.$q.notify({
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
message: 'Processing payment...',
|
message: 'Processing payment...'
|
||||||
icon: null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.payInvoice(this.g.wallet, this.send.data.bolt11)
|
.payInvoice(this.g.wallet, this.parse.data.request)
|
||||||
.then(function (response) {
|
.then(response => {
|
||||||
self.send.paymentChecker = setInterval(function () {
|
clearInterval(this.parse.paymentChecker)
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(this.parse.paymentChecker)
|
||||||
|
}, 40000)
|
||||||
|
this.parse.paymentChecker = setInterval(() => {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.getPayment(self.g.wallet, response.data.payment_hash)
|
.getPayment(this.g.wallet, response.data.payment_hash)
|
||||||
.then(function (res) {
|
.then(res => {
|
||||||
if (res.data.paid) {
|
if (res.data.paid) {
|
||||||
self.send.show = false
|
this.parse.show = false
|
||||||
clearInterval(self.send.paymentChecker)
|
clearInterval(this.parse.paymentChecker)
|
||||||
dismissPaymentMsg()
|
dismissPaymentMsg()
|
||||||
self.fetchPayments()
|
this.fetchPayments()
|
||||||
|
this.fetchBalance()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, 2000)
|
}, 2000)
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(err => {
|
||||||
dismissPaymentMsg()
|
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) {
|
deleteWallet: function (walletId, user) {
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog('Are you sure you want to delete this wallet?')
|
.confirmDialog('Are you sure you want to delete this wallet?')
|
||||||
.onOk(function () {
|
.onOk(() => {
|
||||||
LNbits.href.deleteWallet(walletId, user)
|
LNbits.href.deleteWallet(walletId, user)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fetchPayments: function (checkPending) {
|
fetchPayments: function (checkPending) {
|
||||||
var self = this
|
|
||||||
|
|
||||||
return LNbits.api
|
return LNbits.api
|
||||||
.getPayments(this.g.wallet, checkPending)
|
.getPayments(this.g.wallet, checkPending)
|
||||||
.then(function (response) {
|
.then(response => {
|
||||||
self.payments = response.data
|
this.payments = response.data
|
||||||
.map(function (obj) {
|
.map(obj => {
|
||||||
return LNbits.map.payment(obj)
|
return LNbits.map.payment(obj)
|
||||||
})
|
})
|
||||||
.sort(function (a, b) {
|
.sort((a, b) => {
|
||||||
return b.time - a.time
|
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 () {
|
checkPendingPayments: function () {
|
||||||
var dismissMsg = this.$q.notify({
|
var dismissMsg = this.$q.notify({
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
message: 'Checking pending transactions...',
|
message: 'Checking pending transactions...'
|
||||||
icon: null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.fetchPayments(true).then(function () {
|
this.fetchPayments(true).then(() => {
|
||||||
dismissMsg()
|
dismissMsg()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -404,14 +622,16 @@ new Vue({
|
|||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
payments: function () {
|
payments: function () {
|
||||||
EventHub.$emit('update-wallet-balance', [this.g.wallet.id, this.balance])
|
this.fetchBalance()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created: function () {
|
created: function () {
|
||||||
|
this.fetchBalance()
|
||||||
this.fetchPayments()
|
this.fetchPayments()
|
||||||
setTimeout(this.checkPendingPayments(), 1200)
|
setTimeout(this.checkPendingPayments(), 1200)
|
||||||
},
|
},
|
||||||
mounted: function () {
|
mounted: function () {
|
||||||
|
// show disclaimer
|
||||||
if (
|
if (
|
||||||
this.$refs.disclaimer &&
|
this.$refs.disclaimer &&
|
||||||
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
|
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
|
||||||
@@ -419,5 +639,10 @@ new Vue({
|
|||||||
this.disclaimerDialog.show = true
|
this.disclaimerDialog.show = true
|
||||||
this.$q.localStorage.set('lnbits.disclaimerShown', 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"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
: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
|
<q-expansion-item
|
||||||
group="api"
|
group="api"
|
||||||
dense
|
dense
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
{% extends "base.html" %}
|
||||||
%} {% block scripts %} {{ window_vars(user, wallet) }}
|
<!---->
|
||||||
|
{% from "macros.jinja" import window_vars with context %}
|
||||||
|
<!---->
|
||||||
|
{% block scripts %} {{ window_vars(user, wallet) }}
|
||||||
<script src="/core/static/js/wallet.js"></script>
|
<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="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h3 class="q-my-none">
|
<h3 class="q-my-none">
|
||||||
<strong>{% raw %}{{ fbalance }}{% endraw %}</strong> sat
|
<strong>{% raw %}{{ formattedBalance }}{% endraw %}</strong> sat
|
||||||
</h3>
|
</h3>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<div class="row q-pb-md q-px-md q-col-gutter-md">
|
<div class="row q-pb-md q-px-md q-col-gutter-md">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
color="purple"
|
color="deep-purple"
|
||||||
class="full-width"
|
class="full-width"
|
||||||
@click="showSendDialog"
|
@click="showParseDialog"
|
||||||
>Send</q-btn
|
>Paste Request</q-btn
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -26,9 +33,19 @@
|
|||||||
color="deep-purple"
|
color="deep-purple"
|
||||||
class="full-width"
|
class="full-width"
|
||||||
@click="showReceiveDialog"
|
@click="showReceiveDialog"
|
||||||
>Receive</q-btn
|
>Create Invoice</q-btn
|
||||||
>
|
>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
@@ -85,7 +102,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
<q-td auto-width class="lnbits__q-table__icon-td">
|
<q-td auto-width class="text-center">
|
||||||
<q-icon
|
<q-icon
|
||||||
v-if="props.row.isPaid"
|
v-if="props.row.isPaid"
|
||||||
size="14px"
|
size="14px"
|
||||||
@@ -102,7 +119,11 @@
|
|||||||
<q-tooltip>Pending</q-tooltip>
|
<q-tooltip>Pending</q-tooltip>
|
||||||
</q-icon>
|
</q-icon>
|
||||||
</q-td>
|
</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">
|
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
|
||||||
<a
|
<a
|
||||||
class="inherit"
|
class="inherit"
|
||||||
@@ -114,50 +135,59 @@
|
|||||||
{{ props.row.memo }}
|
{{ props.row.memo }}
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td auto-width key="date" :props="props">
|
<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>
|
||||||
<q-td auto-width key="sat" :props="props">
|
<q-td auto-width key="sat" :props="props">
|
||||||
{{ props.row.fsat }}
|
{{ props.row.fsat }}
|
||||||
</q-td>
|
</q-td>
|
||||||
|
<q-td auto-width key="fee" :props="props">
|
||||||
|
{{ props.row.fee }}
|
||||||
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
|
|
||||||
<q-dialog v-model="props.expand" :props="props">
|
<q-dialog v-model="props.expand" :props="props">
|
||||||
<q-card
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
v-if="props.row.amount > 0 && props.row.pending"
|
|
||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
|
||||||
>
|
|
||||||
<div class="text-center q-mb-lg">
|
<div class="text-center q-mb-lg">
|
||||||
<a :href="'lightning:' + receive.paymentReq">
|
<div v-if="props.row.isIn && props.row.pending">
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
||||||
<qrcode
|
Invoice waiting to be paid
|
||||||
:value="receive.paymentReq"
|
<lnbits-payment-details
|
||||||
:options="{width: 340}"
|
:payment="props.row"
|
||||||
class="rounded-borders"
|
></lnbits-payment-details>
|
||||||
></qrcode>
|
<div v-if="props.row.bolt11" class="text-center q-mb-lg">
|
||||||
</q-responsive>
|
<a :href="'lightning:' + props.row.bolt11">
|
||||||
</a>
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
</div>
|
<qrcode
|
||||||
<div class="row q-mt-lg">
|
:value="props.row.bolt11"
|
||||||
<q-btn
|
:options="{width: 340}"
|
||||||
outline
|
class="rounded-borders"
|
||||||
color="grey"
|
></qrcode>
|
||||||
@click="copyText(receive.paymentReq)"
|
</q-responsive>
|
||||||
>Copy invoice</q-btn
|
</a>
|
||||||
>
|
</div>
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
<div class="row q-mt-lg">
|
||||||
>Close</q-btn
|
<q-btn
|
||||||
>
|
outline
|
||||||
</div>
|
color="grey"
|
||||||
</q-card>
|
@click="copyText(props.row.bolt11)"
|
||||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
>Copy invoice</q-btn
|
||||||
<div class="text-center q-mb-lg">
|
>
|
||||||
<div v-if="props.row.isPaid && props.row.isIn">
|
<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
|
<q-icon
|
||||||
size="18px"
|
size="18px"
|
||||||
:name="'call_received'"
|
:name="'call_received'"
|
||||||
:color="'green'"
|
:color="'green'"
|
||||||
></q-icon>
|
></q-icon>
|
||||||
Payment Received
|
Payment Received
|
||||||
|
<lnbits-payment-details
|
||||||
|
:payment="props.row"
|
||||||
|
></lnbits-payment-details>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="props.row.isPaid && props.row.isOut">
|
<div v-else-if="props.row.isPaid && props.row.isOut">
|
||||||
<q-icon
|
<q-icon
|
||||||
@@ -166,14 +196,16 @@
|
|||||||
:color="'pink'"
|
:color="'pink'"
|
||||||
></q-icon>
|
></q-icon>
|
||||||
Payment Sent
|
Payment Sent
|
||||||
|
<lnbits-payment-details
|
||||||
|
:payment="props.row"
|
||||||
|
></lnbits-payment-details>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else-if="props.row.isOut && props.row.pending">
|
||||||
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
||||||
Outgoing payment pending
|
Outgoing payment pending
|
||||||
</div>
|
<lnbits-payment-details
|
||||||
<q-tooltip>Payment Hash</q-tooltip>
|
:payment="props.row"
|
||||||
<div class="text-wrap mono q-pa-md">
|
></lnbits-payment-details>
|
||||||
{{ props.row.payment_hash }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -184,7 +216,6 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
@@ -228,18 +259,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
|
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
|
||||||
|
{% raw %}
|
||||||
<q-card
|
<q-card
|
||||||
v-if="!receive.paymentReq"
|
v-if="!receive.paymentReq"
|
||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
>
|
>
|
||||||
<q-form @submit="createInvoice" class="q-gutter-md">
|
<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
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.number="receive.data.amount"
|
v-model.number="receive.data.amount"
|
||||||
type="number"
|
type="number"
|
||||||
label="Amount (sat) *"
|
label="Amount (sat) *"
|
||||||
|
:min="receive.minMax[0]"
|
||||||
|
:max="receive.minMax[1]"
|
||||||
|
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||||
></q-input>
|
></q-input>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
@@ -254,8 +293,12 @@
|
|||||||
color="deep-purple"
|
color="deep-purple"
|
||||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||||
type="submit"
|
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>
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||||
</div>
|
</div>
|
||||||
<q-spinner
|
<q-spinner
|
||||||
@@ -284,36 +327,143 @@
|
|||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
{% endraw %}
|
||||||
</q-dialog>
|
</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">
|
<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
|
<q-form
|
||||||
v-if="!sendCamera.show"
|
v-if="!parse.camera.show"
|
||||||
@submit="decodeInvoice"
|
@submit="decodeRequest"
|
||||||
class="q-gutter-md"
|
class="q-gutter-md"
|
||||||
>
|
>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="send.data.bolt11"
|
v-model.trim="parse.data.request"
|
||||||
type="textarea"
|
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>
|
</q-input>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
color="deep-purple"
|
color="deep-purple"
|
||||||
:disable="send.data.bolt11 == ''"
|
:disable="parse.data.request == ''"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Read invoice</q-btn
|
>Read</q-btn
|
||||||
>
|
>
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
>Cancel</q-btn
|
>Cancel</q-btn
|
||||||
@@ -328,39 +478,29 @@
|
|||||||
></qrcode-stream>
|
></qrcode-stream>
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
|
||||||
>Cancel</q-btn
|
Cancel
|
||||||
>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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-card>
|
||||||
</q-dialog>
|
</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 class="q-pa-sm" style="width: 800px; max-width: unset">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<canvas ref="canvas" width="600" height="400"></canvas>
|
<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 http import HTTPStatus
|
||||||
from binascii import unhexlify
|
from binascii import unhexlify
|
||||||
|
from typing import Dict, Union
|
||||||
|
|
||||||
from lnbits import bolt11
|
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.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"])
|
@core_app.route("/api/v1/payments", methods=["GET"])
|
||||||
@@ -17,12 +40,9 @@ async def api_payments():
|
|||||||
delete_expired_invoices()
|
delete_expired_invoices()
|
||||||
|
|
||||||
for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True):
|
for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True):
|
||||||
if payment.is_out:
|
payment.check_pending()
|
||||||
payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
|
|
||||||
else:
|
|
||||||
payment.set_pending(WALLET.get_invoice_status(payment.checking_id).pending)
|
|
||||||
|
|
||||||
return jsonify(g.wallet.get_payments()), HTTPStatus.OK
|
return jsonify(g.wallet.get_payments(pending=True)), HTTPStatus.OK
|
||||||
|
|
||||||
|
|
||||||
@api_check_wallet_key("invoice")
|
@api_check_wallet_key("invoice")
|
||||||
@@ -31,6 +51,7 @@ async def api_payments():
|
|||||||
"amount": {"type": "integer", "min": 1, "required": True},
|
"amount": {"type": "integer", "min": 1, "required": True},
|
||||||
"memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
|
"memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
|
||||||
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
|
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
|
||||||
|
"lnurl_callback": {"type": "string", "nullable": True, "required": False},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
async def api_payments_create_invoice():
|
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
|
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
invoice = bolt11.decode(payment_request)
|
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 (
|
return (
|
||||||
jsonify(
|
jsonify(
|
||||||
{
|
{
|
||||||
@@ -57,6 +99,7 @@ async def api_payments_create_invoice():
|
|||||||
"payment_request": payment_request,
|
"payment_request": payment_request,
|
||||||
# maintain backwards compatibility with API clients:
|
# maintain backwards compatibility with API clients:
|
||||||
"checking_id": invoice.payment_hash,
|
"checking_id": invoice.payment_hash,
|
||||||
|
"lnurl_response": lnurl_response,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
HTTPStatus.CREATED,
|
HTTPStatus.CREATED,
|
||||||
@@ -72,10 +115,10 @@ async def api_payments_pay_invoice():
|
|||||||
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
|
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
|
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print(e)
|
traceback.print_exc(7)
|
||||||
g.db.rollback()
|
g.db.rollback()
|
||||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
return (
|
return (
|
||||||
jsonify(
|
jsonify(
|
||||||
@@ -97,6 +140,88 @@ async def api_payments_create():
|
|||||||
return await api_payments_create_invoice()
|
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"])
|
@core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"])
|
||||||
@api_check_wallet_key("invoice")
|
@api_check_wallet_key("invoice")
|
||||||
async def api_payment(payment_hash):
|
async def api_payment(payment_hash):
|
||||||
@@ -105,20 +230,146 @@ async def api_payment(payment_hash):
|
|||||||
if not payment:
|
if not payment:
|
||||||
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
|
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
|
||||||
elif not payment.pending:
|
elif not payment.pending:
|
||||||
return jsonify({"paid": True}), HTTPStatus.OK
|
return jsonify({"paid": True, "preimage": payment.preimage}), HTTPStatus.OK
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if payment.is_uncheckable:
|
payment.check_pending()
|
||||||
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
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({"paid": False}), HTTPStatus.OK
|
return jsonify({"paid": False}), HTTPStatus.OK
|
||||||
|
|
||||||
if is_paid:
|
return jsonify({"paid": not payment.pending, "preimage": payment.preimage}), HTTPStatus.OK
|
||||||
payment.set_pending(False)
|
|
||||||
return jsonify({"paid": True}), 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
|
import trio # type: ignore
|
||||||
from http import HTTPStatus
|
import httpx
|
||||||
from os import path
|
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.core import core_app
|
||||||
from lnbits.decorators import check_user_exists, validate_uuids
|
from lnbits.decorators import check_user_exists, validate_uuids
|
||||||
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE
|
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE
|
||||||
|
from lnbits.tasks import run_on_pseudo_request
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_account,
|
create_account,
|
||||||
@@ -13,6 +17,7 @@ from ..crud import (
|
|||||||
create_wallet,
|
create_wallet,
|
||||||
delete_wallet,
|
delete_wallet,
|
||||||
)
|
)
|
||||||
|
from ..services import redeem_lnurl_withdraw
|
||||||
|
|
||||||
|
|
||||||
@core_app.route("/favicon.ico")
|
@core_app.route("/favicon.ico")
|
||||||
@@ -73,12 +78,11 @@ async def wallet():
|
|||||||
|
|
||||||
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
|
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.")
|
abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||||
|
|
||||||
return await render_template(
|
return await render_template("core/wallet.html", user=user, wallet=wallet, service_fee=service_fee)
|
||||||
"core/wallet.html", user=user, wallet=user.get_wallet(wallet_id), service_fee=service_fee
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.route("/deletewallet")
|
@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.wallet", usr=g.user.id, wal=user_wallet_ids[0]))
|
||||||
|
|
||||||
return redirect(url_for("core.home"))
|
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 = sqlite3.connect(db_path)
|
||||||
self.connection.row_factory = sqlite3.Row
|
self.connection.row_factory = sqlite3.Row
|
||||||
self.cursor = self.connection.cursor()
|
self.cursor = self.connection.cursor()
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.__exit__(None, None, None)
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
if self.closed:
|
||||||
|
return
|
||||||
|
|
||||||
if exc_val:
|
if exc_val:
|
||||||
self.connection.rollback()
|
self.connection.rollback()
|
||||||
self.cursor.close()
|
self.cursor.close()
|
||||||
self.cursor.close()
|
self.connection.close()
|
||||||
else:
|
else:
|
||||||
self.connection.commit()
|
self.connection.commit()
|
||||||
self.cursor.close()
|
self.cursor.close()
|
||||||
self.connection.close()
|
self.connection.close()
|
||||||
|
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
def commit(self):
|
def commit(self):
|
||||||
self.connection.commit()
|
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
|
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):
|
def wrap(view):
|
||||||
@wraps(view)
|
@wraps(view)
|
||||||
async def wrapped_view(**kwargs):
|
async def wrapped_view(**kwargs):
|
||||||
try:
|
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:
|
except KeyError:
|
||||||
return (
|
return (
|
||||||
jsonify({"message": "`X-Api-Key` header missing."}),
|
jsonify({"message": "`X-Api-Key` header missing."}),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import requests
|
import httpx
|
||||||
from quart import g, jsonify, request, abort
|
from quart import g, jsonify, request, abort
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl
|
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"}
|
wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo, extra={"tag": "amilk"}
|
||||||
)
|
)
|
||||||
|
|
||||||
r = requests.get(
|
r = httpx.get(
|
||||||
withdraw_res.callback.base,
|
withdraw_res.callback.base,
|
||||||
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
|
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.")
|
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||||
|
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
import requests
|
import httpx
|
||||||
from lnbits.db import open_ext_db
|
from lnbits.db import open_ext_db
|
||||||
from lnbits.settings import WALLET
|
from lnbits.settings import WALLET
|
||||||
from .models import Products, Orders, Indexers
|
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:
|
with open_ext_db("diagonalley") as db:
|
||||||
roww = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
|
roww = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
|
||||||
try:
|
try:
|
||||||
x = requests.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
|
x = httpx.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
|
||||||
if x.status_code == 200:
|
if x.status_code == 200:
|
||||||
print(x)
|
print(x)
|
||||||
print("poo")
|
print("poo")
|
||||||
@@ -158,7 +158,7 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
|
|||||||
|
|
||||||
for r in rows:
|
for r in rows:
|
||||||
try:
|
try:
|
||||||
x = requests.get(r["indexeraddress"] + "/" + r["ratingkey"])
|
x = httpx.get(r["indexeraddress"] + "/" + r["ratingkey"])
|
||||||
if x.status_code == 200:
|
if x.status_code == 200:
|
||||||
with open_ext_db("diagonalley") as db:
|
with open_ext_db("diagonalley") as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
label="Your email "
|
label="Your email "
|
||||||
></q-input>
|
></q-input>
|
||||||
|
|
||||||
<p>{% raw %}{{amountWords}}{% endraw %}</p>
|
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
# add your dependencies here
|
# add your dependencies here
|
||||||
|
|
||||||
# import json
|
# 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 quart import jsonify
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ async def lndhub_getinfo():
|
|||||||
)
|
)
|
||||||
async def lndhub_auth():
|
async def lndhub_auth():
|
||||||
token = (
|
token = (
|
||||||
g.data["token"]
|
g.data["refresh_token"]
|
||||||
if "token" in g.data and g.data["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")
|
else urlsafe_b64encode((g.data["login"] + ":" + g.data["password"]).encode("utf-8")).decode("ascii")
|
||||||
)
|
)
|
||||||
return jsonify({"refresh_token": token, "access_token": token})
|
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_api import * # noqa
|
||||||
from .views 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 typing import List, Optional, Union
|
||||||
|
|
||||||
from lnbits.db import open_ext_db
|
from lnbits.db import open_ext_db
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from quart import g
|
||||||
|
|
||||||
from .models import PayLink
|
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:
|
with open_ext_db("lnurlp") as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO pay_links (
|
INSERT INTO pay_links (
|
||||||
wallet,
|
wallet,
|
||||||
description,
|
description,
|
||||||
amount,
|
min,
|
||||||
|
max,
|
||||||
served_meta,
|
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
|
link_id = db.cursor.lastrowid
|
||||||
return get_pay_link(link_id)
|
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:
|
with open_ext_db("lnurlp") as db:
|
||||||
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
|
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
|
||||||
|
|
||||||
return PayLink.from_row(row) if row else None
|
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]:
|
def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
|
||||||
if isinstance(wallet_ids, str):
|
if isinstance(wallet_ids, str):
|
||||||
wallet_ids = [wallet_ids]
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
with open_ext_db("lnurlp") as db:
|
with open_ext_db("lnurlp") as db:
|
||||||
q = ",".join(["?"] * len(wallet_ids))
|
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]
|
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()])
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
|
||||||
with open_ext_db("lnurlp") as db:
|
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
|
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()])
|
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
|
||||||
|
|
||||||
with open_ext_db("lnurlp") as db:
|
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
|
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:
|
with open_ext_db("lnurlp") as db:
|
||||||
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
|
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
|
import json
|
||||||
|
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
|
||||||
from quart import url_for
|
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 import Lnurl, encode as lnurl_encode
|
||||||
from lnurl.types import LnurlPayMetadata
|
from lnurl.types import LnurlPayMetadata
|
||||||
from sqlite3 import Row
|
from lnurl.models import LnurlPaySuccessAction, MessageAction, UrlAction
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
|
|
||||||
class PayLink(NamedTuple):
|
class PayLink(NamedTuple):
|
||||||
id: str
|
id: int
|
||||||
wallet: str
|
wallet: str
|
||||||
description: str
|
description: str
|
||||||
amount: int
|
min: int
|
||||||
served_meta: int
|
served_meta: int
|
||||||
served_pr: int
|
served_pr: int
|
||||||
|
webhook_url: str
|
||||||
|
success_text: str
|
||||||
|
success_url: str
|
||||||
|
currency: str
|
||||||
|
comment_chars: int
|
||||||
|
max: int
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: Row) -> "PayLink":
|
def from_row(cls, row: Row) -> "PayLink":
|
||||||
@@ -27,3 +35,16 @@ class PayLink(NamedTuple):
|
|||||||
@property
|
@property
|
||||||
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||||
return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
|
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>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h6 class="text-subtitle1 q-mb-sm q-mt-none">LNbits LNURL-pay link</h6>
|
<h6 class="text-subtitle1 q-mb-sm q-mt-none">LNbits LNURL-pay link</h6>
|
||||||
<p class="q-my-none">
|
<p class="q-my-none">Use an LNURL compatible bitcoin wallet to pay.</p>
|
||||||
Use a LNURL compatible bitcoin wallet to claim the sats.
|
|
||||||
</p>
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
|
|||||||
@@ -16,25 +16,22 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<h5 class="text-subtitle1 q-my-none">Pay links</h5>
|
<h5 class="text-subtitle1 q-my-none">Pay links</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
|
||||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:data="payLinks"
|
:data="payLinks"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
:columns="payLinksTable.columns"
|
|
||||||
:pagination.sync="payLinksTable.pagination"
|
:pagination.sync="payLinksTable.pagination"
|
||||||
>
|
>
|
||||||
{% raw %}
|
{% raw %}
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
<q-th auto-width>Description</q-th>
|
||||||
{{ col.label }}
|
<q-th auto-width>Amount</q-th>
|
||||||
</q-th>
|
<q-th auto-width>Currency</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,8 +57,39 @@
|
|||||||
@click="openQrCodeDialog(props.row.id)"
|
@click="openQrCodeDialog(props.row.id)"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
<q-td auto-width>{{ props.row.description }}</q-td>
|
||||||
{{ col.value }}
|
<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>
|
||||||
<q-td auto-width>
|
<q-td auto-width>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -124,12 +152,76 @@
|
|||||||
type="text"
|
type="text"
|
||||||
label="Item description *"
|
label="Item description *"
|
||||||
></q-input>
|
></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
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.number="formDialog.data.amount"
|
v-model.number="formDialog.data.comment_chars"
|
||||||
type="number"
|
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>
|
></q-input>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -147,9 +239,10 @@
|
|||||||
formDialog.data.wallet == null ||
|
formDialog.data.wallet == null ||
|
||||||
formDialog.data.description == null ||
|
formDialog.data.description == null ||
|
||||||
(
|
(
|
||||||
formDialog.data.amount == null ||
|
formDialog.data.min == null ||
|
||||||
formDialog.data.amount < 1
|
formDialog.data.min <= 0
|
||||||
)"
|
)
|
||||||
|
"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Create pay link</q-btn
|
>Create pay link</q-btn
|
||||||
>
|
>
|
||||||
@@ -173,7 +266,16 @@
|
|||||||
</q-responsive>
|
</q-responsive>
|
||||||
<p style="word-break: break-all">
|
<p style="word-break: break-all">
|
||||||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
<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>
|
</p>
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
<div class="row q-mt-lg q-gutter-sm">
|
<div class="row q-mt-lg q-gutter-sm">
|
||||||
@@ -191,7 +293,6 @@
|
|||||||
>Shareable link</q-btn
|
>Shareable link</q-btn
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="!qrCodeDialog.data.is_unique"
|
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
icon="print"
|
icon="print"
|
||||||
@@ -205,194 +306,5 @@
|
|||||||
</q-dialog>
|
</q-dialog>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script>
|
<script src="/lnurlp/static/js/index.js"></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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
import hashlib
|
from quart import g, jsonify, request
|
||||||
from quart import g, jsonify, request, url_for
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
|
||||||
|
|
||||||
from lnbits.core.crud import get_user
|
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.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 (
|
from .crud import (
|
||||||
create_pay_link,
|
create_pay_link,
|
||||||
get_pay_link,
|
get_pay_link,
|
||||||
get_pay_links,
|
get_pay_links,
|
||||||
update_pay_link,
|
update_pay_link,
|
||||||
increment_pay_link,
|
|
||||||
delete_pay_link,
|
delete_pay_link,
|
||||||
)
|
)
|
||||||
|
from .helpers import get_fiat_rate
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
|
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
|
||||||
@@ -59,10 +56,24 @@ async def api_link_retrieve(link_id):
|
|||||||
@api_validate_post_request(
|
@api_validate_post_request(
|
||||||
schema={
|
schema={
|
||||||
"description": {"type": "string", "empty": False, "required": True},
|
"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):
|
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:
|
if link_id:
|
||||||
link = get_pay_link(link_id)
|
link = get_pay_link(link_id)
|
||||||
|
|
||||||
@@ -95,37 +106,11 @@ async def api_link_delete(link_id):
|
|||||||
return "", HTTPStatus.NO_CONTENT
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
|
@lnurlp_ext.route("/api/v1/rate/<currency>", methods=["GET"])
|
||||||
async def api_lnurl_response(link_id):
|
async def api_check_fiat_rate(currency):
|
||||||
link = increment_pay_link(link_id, served_meta=1)
|
try:
|
||||||
if not link:
|
rate = await get_fiat_rate(currency)
|
||||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
except AssertionError:
|
||||||
|
rate = None
|
||||||
|
|
||||||
url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True)
|
return jsonify({"rate": rate}), HTTPStatus.OK
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async def api_usermanager_users_delete(user_id):
|
|||||||
async def api_usermanager_activate_extension():
|
async def api_usermanager_activate_extension():
|
||||||
user = get_user(g.data["userid"])
|
user = get_user(g.data["userid"])
|
||||||
if not user:
|
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"])
|
update_user_extension(user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"])
|
||||||
return jsonify({"extension": "updated"}), HTTPStatus.CREATED
|
return jsonify({"extension": "updated"}), HTTPStatus.CREATED
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ new Vue({
|
|||||||
|
|
||||||
this.qrCodeDialog.data = _.clone(link)
|
this.qrCodeDialog.data = _.clone(link)
|
||||||
console.log(this.qrCodeDialog.data)
|
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
|
this.qrCodeDialog.show = true
|
||||||
},
|
},
|
||||||
openUpdateDialog: function (linkId) {
|
openUpdateDialog: function (linkId) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
%} {% block scripts %} {{ window_vars(user) }}
|
%} {% block 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 %}
|
{% endblock %} {% block page %}
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
@@ -287,13 +287,13 @@
|
|||||||
|
|
||||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||||
{% raw %}
|
|
||||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||||
<qrcode
|
<qrcode
|
||||||
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
|
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
|
||||||
:options="{width: 800}"
|
:options="{width: 800}"
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></qrcode>
|
></qrcode>
|
||||||
|
{% raw %}
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
<p style="word-break: break-all">
|
<p style="word-break: break-all">
|
||||||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "print.html" %} {% block page %}
|
{% extends "print.html" %} {% block page %}
|
||||||
|
|
||||||
<div class="row justify-center">
|
<div class="row">
|
||||||
<div class="col-12 col-sm-8 col-lg-6 text-center" id="vue">
|
<div class="" id="vue">
|
||||||
{% for page in link %}
|
{% for page in link %}
|
||||||
<page size="A4" id="pdfprint">
|
<page size="A4" id="pdfprint">
|
||||||
<table style="width: 100%">
|
<table style="width: 100%">
|
||||||
@@ -43,8 +43,11 @@
|
|||||||
@media print {
|
@media print {
|
||||||
body,
|
body,
|
||||||
page {
|
page {
|
||||||
margin: 0;
|
margin: 0px !important;
|
||||||
box-shadow: 0;
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.q-page {
|
||||||
|
padding: 0px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -54,7 +57,6 @@
|
|||||||
|
|
||||||
new Vue({
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [windowMixin],
|
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
theurl: location.protocol + '//' + location.host,
|
theurl: location.protocol + '//' + location.host,
|
||||||
|
|||||||
@@ -152,13 +152,11 @@ async def api_lnurl_multi_response(unique_hash, id_unique_hash):
|
|||||||
found = True
|
found = True
|
||||||
else:
|
else:
|
||||||
usescsv += "," + x
|
usescsv += "," + x
|
||||||
print(x)
|
|
||||||
print("usescsv: " + usescsv)
|
|
||||||
if not found:
|
if not found:
|
||||||
return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK
|
return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK
|
||||||
|
|
||||||
usescsv = usescsv[1:]
|
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
|
return jsonify(link.lnurl_response.dict()), HTTPStatus.OK
|
||||||
|
|
||||||
|
|
||||||
@@ -189,9 +187,7 @@ async def api_lnurl_callback(unique_hash):
|
|||||||
extra={"tag": "withdraw"},
|
extra={"tag": "withdraw"},
|
||||||
)
|
)
|
||||||
|
|
||||||
changes = {
|
changes = {"open_time": link.wait_time + now, "used": link.used + 1}
|
||||||
"open_time": link.wait_time + now,
|
|
||||||
}
|
|
||||||
|
|
||||||
update_withdraw_link(link.id, **changes)
|
update_withdraw_link(link.id, **changes)
|
||||||
except ValueError as e:
|
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]:
|
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]:
|
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:
|
elif minified not in paths:
|
||||||
paths.append(path)
|
paths.append(path)
|
||||||
|
|
||||||
return paths
|
return sorted(paths)
|
||||||
|
|
||||||
|
|
||||||
def url_for_vendored(abspath: str) -> str:
|
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.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:
|
class ASGIProxyFix(TrioASGIHTTPConnection):
|
||||||
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):
|
def _create_request_from_scope(self, send: Callable) -> Request:
|
||||||
self.app = app
|
headers = Headers()
|
||||||
self.x_for = x_for
|
headers["Remote-Addr"] = (self.scope.get("client") or ["<local>"])[0]
|
||||||
self.x_proto = x_proto
|
for name, value in self.scope["headers"]:
|
||||||
self.x_host = x_host
|
headers.add(name.decode("latin1").title(), value.decode("latin1"))
|
||||||
self.x_port = x_port
|
if self.scope["http_version"] < "1.1":
|
||||||
self.x_prefix = x_prefix
|
headers.setdefault("Host", self.app.config["SERVER_NAME"] or "")
|
||||||
|
|
||||||
if app:
|
path = self.scope["path"]
|
||||||
self.init_app(app)
|
path = path if path[0] == "/" else urlparse(path).path
|
||||||
|
|
||||||
def init_app(self, app):
|
x_proto = self._get_real_value(1, headers.get("X-Forwarded-Proto"))
|
||||||
@app.before_request
|
if x_proto:
|
||||||
async def before_request():
|
self.scope["scheme"] = x_proto
|
||||||
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(self.x_proto, request.headers.get("X-Forwarded-Proto"))
|
x_host = self._get_real_value(1, headers.get("X-Forwarded-Host"))
|
||||||
if x_proto:
|
if x_host:
|
||||||
request.scheme = x_proto
|
headers["host"] = x_host.lower()
|
||||||
|
|
||||||
x_host = self._get_real_value(self.x_host, request.headers.get("X-Forwarded-Host"))
|
return self.app.request_class(
|
||||||
if x_host:
|
self.scope["method"],
|
||||||
request.headers["host"] = x_host.lower()
|
self.scope["scheme"],
|
||||||
parts = x_host.split(":", 1)
|
path,
|
||||||
# environ["SERVER_NAME"] = parts[0]
|
self.scope["query_string"],
|
||||||
# if len(parts) == 2:
|
headers,
|
||||||
# environ["SERVER_PORT"] = parts[1]
|
self.scope.get("root_path", ""),
|
||||||
|
self.scope["http_version"],
|
||||||
x_port = self._get_real_value(self.x_port, request.headers.get("X-Forwarded-Port"))
|
max_content_length=self.app.config["MAX_CONTENT_LENGTH"],
|
||||||
if x_port:
|
body_timeout=self.app.config["BODY_TIMEOUT"],
|
||||||
host = request.host
|
send_push_promise=partial(self._send_push_promise, send),
|
||||||
if host:
|
scope=self.scope,
|
||||||
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
|
|
||||||
|
|
||||||
def _get_real_value(self, trusted: int, value: Optional[str]) -> Optional[str]:
|
def _get_real_value(self, trusted: int, value: Optional[str]) -> Optional[str]:
|
||||||
"""Get the real value from a list header based on the configured
|
"""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] != "\\\\":
|
if not is_filename or value[:2] != "\\\\":
|
||||||
return value.replace("\\\\", "\\").replace('\\"', '"')
|
return value.replace("\\\\", "\\").replace('\\"', '"')
|
||||||
return value
|
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
|
import importlib
|
||||||
|
|
||||||
from environs import Env # type: ignore
|
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)
|
PREFER_SECURE_URLS = env.bool("LNBITS_FORCE_HTTPS", default=True)
|
||||||
|
|
||||||
SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0)
|
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'
|
window.LOCALE = 'en'
|
||||||
|
window.EventHub = new Vue()
|
||||||
var EventHub = new Vue()
|
window.LNbits = {
|
||||||
|
|
||||||
var LNbits = {
|
|
||||||
api: {
|
api: {
|
||||||
request: function (method, url, apiKey, data) {
|
request: function (method, url, apiKey, data) {
|
||||||
return axios({
|
return axios({
|
||||||
@@ -16,11 +14,12 @@ var LNbits = {
|
|||||||
data: data
|
data: data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
createInvoice: function (wallet, amount, memo) {
|
createInvoice: function (wallet, amount, memo, lnurlCallback = null) {
|
||||||
return this.request('post', '/api/v1/payments', wallet.inkey, {
|
return this.request('post', '/api/v1/payments', wallet.inkey, {
|
||||||
out: false,
|
out: false,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
memo: memo
|
memo: memo,
|
||||||
|
lnurl_callback: lnurlCallback
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
payInvoice: function (wallet, bolt11) {
|
payInvoice: function (wallet, bolt11) {
|
||||||
@@ -29,6 +28,30 @@ var LNbits = {
|
|||||||
bolt11: bolt11
|
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) {
|
getPayments: function (wallet, checkPending) {
|
||||||
var query_param = checkPending ? '?check_pending' : ''
|
var query_param = checkPending ? '?check_pending' : ''
|
||||||
return this.request(
|
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: {
|
href: {
|
||||||
createWallet: function (walletName, userId) {
|
createWallet: function (walletName, userId) {
|
||||||
window.location.href =
|
window.location.href =
|
||||||
@@ -88,7 +124,7 @@ var LNbits = {
|
|||||||
)
|
)
|
||||||
obj.msat = obj.balance
|
obj.msat = obj.balance
|
||||||
obj.sat = Math.round(obj.balance / 1000)
|
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('')
|
obj.url = ['/wallet?usr=', obj.user, '&wal=', obj.id].join('')
|
||||||
return obj
|
return obj
|
||||||
},
|
},
|
||||||
@@ -112,10 +148,11 @@ var LNbits = {
|
|||||||
new Date(obj.time * 1000),
|
new Date(obj.time * 1000),
|
||||||
'YYYY-MM-DD HH:mm'
|
'YYYY-MM-DD HH:mm'
|
||||||
)
|
)
|
||||||
|
obj.dateFrom = moment(obj.date).fromNow()
|
||||||
obj.msat = obj.amount
|
obj.msat = obj.amount
|
||||||
obj.sat = obj.msat / 1000
|
obj.sat = obj.msat / 1000
|
||||||
obj.tag = obj.extra.tag
|
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.isIn = obj.amount > 0
|
||||||
obj.isOut = obj.amount < 0
|
obj.isOut = obj.amount < 0
|
||||||
obj.isPaid = obj.pending === 0
|
obj.isPaid = obj.pending === 0
|
||||||
@@ -138,13 +175,13 @@ var LNbits = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
formatCurrency: function (value, currency) {
|
formatCurrency: function (value, currency) {
|
||||||
return new Intl.NumberFormat(LOCALE, {
|
return new Intl.NumberFormat(window.LOCALE, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: currency
|
currency: currency
|
||||||
}).format(value)
|
}).format(value)
|
||||||
},
|
},
|
||||||
formatSat: function (value) {
|
formatSat: function (value) {
|
||||||
return new Intl.NumberFormat(LOCALE).format(value)
|
return new Intl.NumberFormat(window.LOCALE).format(value)
|
||||||
},
|
},
|
||||||
notifyApiError: function (error) {
|
notifyApiError: function (error) {
|
||||||
var types = {
|
var types = {
|
||||||
@@ -227,7 +264,7 @@ var LNbits = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var windowMixin = {
|
window.windowMixin = {
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
g: {
|
g: {
|
||||||
@@ -257,17 +294,17 @@ var windowMixin = {
|
|||||||
created: function () {
|
created: function () {
|
||||||
this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode'))
|
this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode'))
|
||||||
if (window.user) {
|
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) {
|
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) {
|
if (window.extensions) {
|
||||||
var user = this.g.user
|
var user = this.g.user
|
||||||
this.g.extensions = Object.freeze(
|
this.g.extensions = Object.freeze(
|
||||||
window.extensions
|
window.extensions
|
||||||
.map(function (data) {
|
.map(function (data) {
|
||||||
return LNbits.map.extension(data)
|
return window.LNbits.map.extension(data)
|
||||||
})
|
})
|
||||||
.map(function (obj) {
|
.map(function (obj) {
|
||||||
if (user) {
|
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', {
|
Vue.component('lnbits-fsat', {
|
||||||
props: {
|
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-font-feature-settings: 'liga';
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-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" />
|
<link rel="stylesheet" type="text/css" href="/static/css/base.css" />
|
||||||
{% block styles %}{% endblock %}
|
{% block styles %}{% endblock %}
|
||||||
<title>
|
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
|
||||||
{% block title %} {% if SITE_TITLE != 'LNbits' %}{{ SITE_TITLE }}{% else
|
|
||||||
%}LNbits{% endif %} {% endblock %}
|
|
||||||
</title>
|
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
|
|||||||
@@ -15,10 +15,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% block styles %}{% endblock %}
|
{% block styles %}{% endblock %}
|
||||||
<title>
|
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
|
||||||
{% block title %} {% if SITE_TITLE != 'LNbits' %}{{ SITE_TITLE }}{% else
|
|
||||||
%}LNbits{% endif %} {% endblock %}
|
|
||||||
</title>
|
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
|
|||||||
+15
-1
@@ -1,5 +1,10 @@
|
|||||||
from abc import ABC, abstractmethod
|
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):
|
class InvoiceResponse(NamedTuple):
|
||||||
@@ -13,6 +18,7 @@ class PaymentResponse(NamedTuple):
|
|||||||
ok: bool
|
ok: bool
|
||||||
checking_id: Optional[str] = None # payment_hash, rcp_id
|
checking_id: Optional[str] = None # payment_hash, rcp_id
|
||||||
fee_msat: int = 0
|
fee_msat: int = 0
|
||||||
|
preimage: Optional[str] = None
|
||||||
error_message: Optional[str] = None
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +31,10 @@ class PaymentStatus(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class Wallet(ABC):
|
class Wallet(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def status(self) -> StatusResponse:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def create_invoice(
|
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
|
||||||
@@ -43,6 +53,10 @@ class Wallet(ABC):
|
|||||||
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Unsupported(Exception):
|
class Unsupported(Exception):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
try:
|
try:
|
||||||
from lightning import LightningRpc # type: ignore
|
from lightning import LightningRpc, RpcError # type: ignore
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
LightningRpc = None
|
LightningRpc = None
|
||||||
|
|
||||||
|
import trio # type: ignore
|
||||||
import random
|
import random
|
||||||
|
import json
|
||||||
|
|
||||||
from os import getenv
|
from os import getenv
|
||||||
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 CLightningWallet(Wallet):
|
class CLightningWallet(Wallet):
|
||||||
@@ -15,34 +18,80 @@ class CLightningWallet(Wallet):
|
|||||||
if LightningRpc is None: # pragma: nocover
|
if LightningRpc is None: # pragma: nocover
|
||||||
raise ImportError("The `pylightning` library must be installed to use `CLightningWallet`.")
|
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(
|
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:
|
) -> InvoiceResponse:
|
||||||
if description_hash:
|
|
||||||
raise Unsupported("description_hash")
|
|
||||||
|
|
||||||
label = "lbl{}".format(random.random())
|
label = "lbl{}".format(random.random())
|
||||||
r = self.l1.invoice(amount * 1000, label, memo, exposeprivatechannels=True)
|
msat = amount * 1000
|
||||||
ok, checking_id, payment_request, error_message = True, r["payment_hash"], r["bolt11"], None
|
|
||||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
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:
|
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||||
r = self.l1.pay(bolt11)
|
try:
|
||||||
ok, checking_id, fee_msat, error_message = True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None
|
r = self.ln.pay(bolt11)
|
||||||
return PaymentResponse(ok, checking_id, fee_msat, error_message)
|
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:
|
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"]:
|
if not r["invoices"]:
|
||||||
return PaymentStatus(False)
|
return PaymentStatus(False)
|
||||||
if r["invoices"][0]["label"] == checking_id:
|
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")
|
raise KeyError("supplied an invalid checking_id")
|
||||||
|
|
||||||
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
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"]:
|
if not r["pays"]:
|
||||||
return PaymentStatus(False)
|
return PaymentStatus(False)
|
||||||
if r["pays"][0]["payment_hash"] == checking_id:
|
if r["pays"][0]["payment_hash"] == checking_id:
|
||||||
@@ -53,3 +102,27 @@ class CLightningWallet(Wallet):
|
|||||||
return PaymentStatus(False)
|
return PaymentStatus(False)
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
raise KeyError("supplied an invalid checking_id")
|
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 os import getenv
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict, AsyncGenerator
|
||||||
from requests import get, post
|
|
||||||
|
|
||||||
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
|
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
|
||||||
|
|
||||||
|
|
||||||
class LNbitsWallet(Wallet):
|
class LNbitsWallet(Wallet):
|
||||||
@@ -10,8 +11,21 @@ class LNbitsWallet(Wallet):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.endpoint = getenv("LNBITS_ENDPOINT")
|
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(
|
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
|
||||||
@@ -22,45 +36,62 @@ class LNbitsWallet(Wallet):
|
|||||||
else:
|
else:
|
||||||
data["memo"] = memo or ""
|
data["memo"] = memo or ""
|
||||||
|
|
||||||
r = post(
|
r = httpx.post(
|
||||||
url=f"{self.endpoint}/api/v1/payments",
|
url=f"{self.endpoint}/api/v1/payments",
|
||||||
headers=self.auth_invoice,
|
headers=self.key,
|
||||||
json=data,
|
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()
|
data = r.json()
|
||||||
checking_id, payment_request = data["checking_id"], data["payment_request"]
|
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)
|
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||||
|
|
||||||
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
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})
|
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 = True, None, 0, None
|
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()
|
data = r.json()
|
||||||
checking_id = data["checking_id"]
|
checking_id = data["checking_id"]
|
||||||
else:
|
|
||||||
error_message = r.json()["message"]
|
|
||||||
|
|
||||||
return PaymentResponse(ok, checking_id, fee_msat, error_message)
|
return PaymentResponse(ok, checking_id, fee_msat, error_message)
|
||||||
|
|
||||||
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
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(None)
|
||||||
|
|
||||||
return PaymentStatus(r.json()["paid"])
|
return PaymentStatus(r.json()["paid"])
|
||||||
|
|
||||||
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
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(None)
|
||||||
|
|
||||||
return PaymentStatus(r.json()["paid"])
|
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:
|
try:
|
||||||
import lnd_grpc # type: ignore
|
import lndgrpc # type: ignore
|
||||||
|
from lndgrpc.common import ln # type: ignore
|
||||||
except ImportError: # pragma: nocover
|
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 base64
|
||||||
|
import hashlib
|
||||||
from os import getenv
|
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):
|
class LndWallet(Wallet):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
if lnd_grpc is None: # pragma: nocover
|
if lndgrpc is None: # pragma: nocover
|
||||||
raise ImportError("The `lnd-grpc` library must be installed to use `LndWallet`.")
|
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")
|
endpoint = getenv("LND_GRPC_ENDPOINT")
|
||||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||||
self.port = getenv("LND_GRPC_PORT")
|
self.port = int(getenv("LND_GRPC_PORT"))
|
||||||
self.auth_admin = getenv("LND_ADMIN_MACAROON")
|
self.cert_path = getenv("LND_GRPC_CERT") or getenv("LND_CERT")
|
||||||
self.auth_invoice = getenv("LND_INVOICE_MACAROON")
|
|
||||||
self.auth_read = getenv("LND_READ_MACAROON")
|
self.macaroon_path = (
|
||||||
self.auth_cert = getenv("LND_CERT")
|
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(
|
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:
|
) -> 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}
|
params: Dict = {"value": amount, "expiry": 600, "private": True}
|
||||||
|
|
||||||
if description_hash:
|
if description_hash:
|
||||||
params["description_hash"] = description_hash # as bytes directly
|
params["description_hash"] = description_hash # as bytes directly
|
||||||
else:
|
else:
|
||||||
params["memo"] = memo or ""
|
params["memo"] = memo or ""
|
||||||
lndResponse = lnd_rpc.add_invoice(**params)
|
|
||||||
decoded_hash = base64.b64encode(lndResponse.r_hash).decode("utf-8").replace("/", "_")
|
try:
|
||||||
ok, checking_id, payment_request, error_message = True, decoded_hash, str(lndResponse.payment_request), None
|
req = ln.Invoice(**params)
|
||||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
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:
|
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||||
lnd_rpc = lnd_grpc.Client(
|
resp = self.rpc.send_payment(payment_request=bolt11)
|
||||||
lnd_dir=None,
|
|
||||||
macaroon_path=self.auth_admin,
|
|
||||||
tls_cert_path=self.auth_cert,
|
|
||||||
network="mainnet",
|
|
||||||
grpc_host=self.endpoint,
|
|
||||||
grpc_port=self.port,
|
|
||||||
)
|
|
||||||
|
|
||||||
payinvoice = lnd_rpc.pay_invoice(
|
if resp.payment_error:
|
||||||
payment_request=bolt11,
|
return PaymentResponse(False, "", 0, None, resp.payment_error)
|
||||||
)
|
|
||||||
|
|
||||||
ok, checking_id, fee_msat, error_message = True, None, 0, None
|
r_hash = hashlib.sha256(resp.payment_preimage).digest()
|
||||||
|
checking_id = stringify_checking_id(r_hash)
|
||||||
if payinvoice.payment_error:
|
fee_msat = resp.payment_route.total_fees_msat
|
||||||
ok, error_message = False, payinvoice.payment_error
|
preimage = resp.payment_preimage.hex()
|
||||||
else:
|
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||||
checking_id = base64.b64encode(payinvoice.payment_hash).decode("utf-8").replace("/", "_")
|
|
||||||
|
|
||||||
return PaymentResponse(ok, checking_id, fee_msat, error_message)
|
|
||||||
|
|
||||||
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
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("_", "/"))
|
resp = self.rpc.lookup_invoice(r_hash.hex())
|
||||||
print(check_id)
|
if resp.settled:
|
||||||
lnd_rpc = lnd_grpc.Client(
|
return PaymentStatus(True)
|
||||||
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)
|
|
||||||
|
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
|
|
||||||
return PaymentStatus(True)
|
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
|
import trio # type: ignore
|
||||||
from typing import Optional, Dict
|
import httpx
|
||||||
|
import json
|
||||||
import base64
|
import base64
|
||||||
from requests import get, post
|
from os import getenv
|
||||||
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
|
from typing import Optional, Dict, AsyncGenerator
|
||||||
|
|
||||||
|
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
|
||||||
|
|
||||||
|
|
||||||
class LndRestWallet(Wallet):
|
class LndRestWallet(Wallet):
|
||||||
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
|
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
||||||
endpoint = getenv("LND_REST_ENDPOINT")
|
endpoint = getenv("LND_REST_ENDPOINT")
|
||||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||||
print(self.endpoint)
|
endpoint = "https://" + endpoint if not endpoint.startswith("http") else endpoint
|
||||||
self.auth_admin = {"Grpc-Metadata-macaroon": getenv("LND_REST_ADMIN_MACAROON")}
|
self.endpoint = endpoint
|
||||||
self.auth_invoice = {"Grpc-Metadata-macaroon": getenv("LND_REST_INVOICE_MACAROON")}
|
|
||||||
self.auth_read = {"Grpc-Metadata-macaroon": getenv("LND_REST_READ_MACAROON")}
|
macaroon = (
|
||||||
self.auth_cert = getenv("LND_REST_CERT")
|
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(
|
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
|
||||||
@@ -30,84 +58,114 @@ class LndRestWallet(Wallet):
|
|||||||
else:
|
else:
|
||||||
data["memo"] = memo or ""
|
data["memo"] = memo or ""
|
||||||
|
|
||||||
r = post(
|
r = httpx.post(
|
||||||
url=f"{self.endpoint}/v1/invoices",
|
url=f"{self.endpoint}/v1/invoices",
|
||||||
headers=self.auth_invoice,
|
headers=self.auth,
|
||||||
verify=self.auth_cert,
|
verify=self.cert,
|
||||||
json=data,
|
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()
|
||||||
data = r.json()
|
payment_request = data["payment_request"]
|
||||||
payment_request = data["payment_request"]
|
payment_hash = base64.b64decode(data["r_hash"]).hex()
|
||||||
|
checking_id = payment_hash
|
||||||
|
|
||||||
r = get(
|
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||||
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)
|
|
||||||
|
|
||||||
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||||
r = post(
|
r = httpx.post(
|
||||||
url=f"{self.endpoint}/v1/channels/transactions",
|
url=f"{self.endpoint}/v1/channels/transactions",
|
||||||
headers=self.auth_admin,
|
headers=self.auth,
|
||||||
verify=self.auth_cert,
|
verify=self.cert,
|
||||||
json={"payment_request": bolt11},
|
json={"payment_request": bolt11},
|
||||||
)
|
timeout=180,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if r.ok:
|
if r.is_error:
|
||||||
checking_id = r.json()["payment_hash"]
|
error_message = r.text
|
||||||
else:
|
try:
|
||||||
error_message = r.json()["error"]
|
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:
|
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
checking_id = checking_id.replace("_", "/")
|
checking_id = checking_id.replace("_", "/")
|
||||||
print(checking_id)
|
r = httpx.get(
|
||||||
r = get(
|
|
||||||
url=f"{self.endpoint}/v1/invoice/{checking_id}",
|
url=f"{self.endpoint}/v1/invoice/{checking_id}",
|
||||||
headers=self.auth_invoice,
|
headers=self.auth,
|
||||||
verify=self.auth_cert,
|
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(None)
|
||||||
|
|
||||||
return PaymentStatus(r.json()["settled"])
|
return PaymentStatus(True)
|
||||||
|
|
||||||
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
r = get(
|
r = httpx.get(
|
||||||
url=f"{self.endpoint}/v1/payments",
|
url=f"{self.endpoint}/v1/payments",
|
||||||
headers=self.auth_admin,
|
headers=self.auth,
|
||||||
verify=self.auth_cert,
|
verify=self.cert,
|
||||||
params={"include_incomplete": "True", "max_payments": "20"},
|
params={"max_payments": "20", "reversed": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not r.ok:
|
if r.is_error:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
payments = [p for p in r.json()["payments"] if p["payment_hash"] == checking_id]
|
# check payment.status:
|
||||||
print(checking_id)
|
# https://api.lightning.community/rest/index.html?python#peersynctype
|
||||||
payment = payments[0] if payments else None
|
|
||||||
|
|
||||||
# check payment.status: https://api.lightning.community/rest/index.html?python#peersynctype
|
|
||||||
statuses = {"UNKNOWN": None, "IN_FLIGHT": None, "SUCCEEDED": True, "FAILED": False}
|
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 os import getenv
|
||||||
from typing import Optional, Dict
|
from http import HTTPStatus
|
||||||
from requests import get, post
|
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):
|
class LNPayWallet(Wallet):
|
||||||
"""https://docs.lnpay.co/"""
|
"""https://docs.lnpay.co/"""
|
||||||
|
|
||||||
def __init__(self):
|
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.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||||
self.auth_admin = getenv("LNPAY_ADMIN_KEY")
|
self.wallet_key = getenv("LNPAY_WALLET_KEY") or getenv("LNPAY_ADMIN_KEY")
|
||||||
self.auth_invoice = getenv("LNPAY_INVOICE_KEY")
|
self.auth = {"X-Api-Key": getenv("LNPAY_API_KEY")}
|
||||||
self.auth_read = getenv("LNPAY_READ_KEY")
|
|
||||||
self.auth_api = {"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(
|
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:
|
) -> InvoiceResponse:
|
||||||
data: Dict = {"num_satoshis": f"{amount}"}
|
data: Dict = {"num_satoshis": f"{amount}"}
|
||||||
if description_hash:
|
if description_hash:
|
||||||
@@ -25,12 +48,18 @@ class LNPayWallet(Wallet):
|
|||||||
else:
|
else:
|
||||||
data["memo"] = memo or ""
|
data["memo"] = memo or ""
|
||||||
|
|
||||||
r = post(
|
r = httpx.post(
|
||||||
url=f"{self.endpoint}/user/wallet/{self.auth_invoice}/invoice",
|
f"{self.endpoint}/wallet/{self.wallet_key}/invoice",
|
||||||
headers=self.auth_api,
|
headers=self.auth,
|
||||||
json=data,
|
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:
|
if ok:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@@ -39,26 +68,64 @@ class LNPayWallet(Wallet):
|
|||||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||||
|
|
||||||
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||||
r = post(
|
r = httpx.post(
|
||||||
url=f"{self.endpoint}/user/wallet/{self.auth_admin}/withdraw",
|
f"{self.endpoint}/wallet/{self.wallet_key}/withdraw",
|
||||||
headers=self.auth_api,
|
headers=self.auth,
|
||||||
json={"payment_request": bolt11},
|
json={"payment_request": bolt11},
|
||||||
|
timeout=180,
|
||||||
)
|
)
|
||||||
ok, checking_id, fee_msat, error_message = r.status_code == 201, None, 0, None
|
|
||||||
|
|
||||||
if ok:
|
try:
|
||||||
checking_id = r.json()["lnTx"]["id"]
|
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:
|
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
return self.get_payment_status(checking_id)
|
return self.get_payment_status(checking_id)
|
||||||
|
|
||||||
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
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)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
statuses = {0: None, 1: True, -1: False}
|
statuses = {0: None, 1: True, -1: False}
|
||||||
return PaymentStatus(statuses[r.json()["settled"]])
|
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 os import getenv
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict, AsyncGenerator
|
||||||
from requests import post
|
|
||||||
|
|
||||||
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
|
from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
|
||||||
|
|
||||||
|
|
||||||
class LntxbotWallet(Wallet):
|
class LntxbotWallet(Wallet):
|
||||||
@@ -11,8 +13,25 @@ class LntxbotWallet(Wallet):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
endpoint = getenv("LNTXBOT_API_ENDPOINT")
|
endpoint = getenv("LNTXBOT_API_ENDPOINT")
|
||||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else 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(
|
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
|
||||||
@@ -23,44 +42,59 @@ class LntxbotWallet(Wallet):
|
|||||||
else:
|
else:
|
||||||
data["memo"] = memo or ""
|
data["memo"] = memo or ""
|
||||||
|
|
||||||
r = post(
|
r = httpx.post(
|
||||||
url=f"{self.endpoint}/addinvoice",
|
f"{self.endpoint}/addinvoice",
|
||||||
headers=self.auth_invoice,
|
headers=self.auth,
|
||||||
json=data,
|
json=data,
|
||||||
|
timeout=40,
|
||||||
)
|
)
|
||||||
ok, checking_id, payment_request, error_message = r.ok, None, None, None
|
|
||||||
|
|
||||||
if r.ok:
|
if r.is_error:
|
||||||
data = r.json()
|
try:
|
||||||
checking_id, payment_request = data["payment_hash"], data["pay_req"]
|
data = r.json()
|
||||||
|
|
||||||
if "error" in data and data["error"]:
|
|
||||||
ok = False
|
|
||||||
error_message = data["message"]
|
error_message = data["message"]
|
||||||
|
except:
|
||||||
|
error_message = r.text
|
||||||
|
pass
|
||||||
|
|
||||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
return InvoiceResponse(False, None, None, 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)
|
|
||||||
|
|
||||||
data = r.json()
|
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:
|
if "preimage" not in data:
|
||||||
return PaymentStatus(False)
|
return PaymentStatus(False)
|
||||||
@@ -68,10 +102,32 @@ class LntxbotWallet(Wallet):
|
|||||||
return PaymentStatus(True)
|
return PaymentStatus(True)
|
||||||
|
|
||||||
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
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)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
statuses = {"complete": True, "failed": False, "pending": None, "unknown": 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 os import getenv
|
||||||
from typing import Optional
|
from typing import Optional, AsyncGenerator
|
||||||
from requests import get, post
|
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):
|
class OpenNodeWallet(Wallet):
|
||||||
@@ -11,8 +16,25 @@ class OpenNodeWallet(Wallet):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
endpoint = getenv("OPENNODE_API_ENDPOINT")
|
endpoint = getenv("OPENNODE_API_ENDPOINT")
|
||||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else 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(
|
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
|
||||||
@@ -20,47 +42,76 @@ class OpenNodeWallet(Wallet):
|
|||||||
if description_hash:
|
if description_hash:
|
||||||
raise Unsupported("description_hash")
|
raise Unsupported("description_hash")
|
||||||
|
|
||||||
r = post(
|
r = httpx.post(
|
||||||
url=f"{self.endpoint}/v1/charges",
|
f"{self.endpoint}/v1/charges",
|
||||||
headers=self.auth_invoice,
|
headers=self.auth,
|
||||||
json={"amount": f"{amount}", "description": memo}, # , "private": True},
|
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:
|
if r.is_error:
|
||||||
data = r.json()["data"]
|
|
||||||
checking_id, payment_request = data["id"], data["lightning_invoice"]["payreq"]
|
|
||||||
else:
|
|
||||||
error_message = r.json()["message"]
|
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:
|
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||||
r = post(url=f"{self.endpoint}/v2/withdrawals", headers=self.auth_admin, json={"type": "ln", "address": bolt11})
|
r = httpx.post(
|
||||||
ok, checking_id, fee_msat, error_message = r.ok, None, 0, None
|
f"{self.endpoint}/v2/withdrawals",
|
||||||
|
headers=self.auth,
|
||||||
|
json={"type": "ln", "address": bolt11},
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
|
||||||
if r.ok:
|
if r.is_error:
|
||||||
data = r.json()["data"]
|
|
||||||
checking_id, fee_msat = data["id"], data["fee"] * 1000
|
|
||||||
else:
|
|
||||||
error_message = r.json()["message"]
|
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:
|
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
r = get(url=f"{self.endpoint}/v1/charge/{checking_id}", headers=self.auth_invoice)
|
r = httpx.get(f"{self.endpoint}/v1/charge/{checking_id}", headers=self.auth)
|
||||||
|
if r.is_error:
|
||||||
if not r.ok:
|
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
statuses = {"processing": None, "paid": True, "unpaid": False}
|
statuses = {"processing": None, "paid": True, "unpaid": False}
|
||||||
return PaymentStatus(statuses[r.json()["data"]["status"]])
|
return PaymentStatus(statuses[r.json()["data"]["status"]])
|
||||||
|
|
||||||
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
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)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
statuses = {"initial": None, "pending": None, "confirmed": True, "error": False, "failed": False}
|
statuses = {"initial": None, "pending": None, "confirmed": True, "error": False, "failed": False}
|
||||||
return PaymentStatus(statuses[r.json()["data"]["status"]])
|
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 random
|
||||||
import requests
|
import json
|
||||||
|
import httpx
|
||||||
from os import getenv
|
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):
|
class SparkError(Exception):
|
||||||
@@ -16,7 +18,7 @@ class UnknownError(Exception):
|
|||||||
|
|
||||||
class SparkWallet(Wallet):
|
class SparkWallet(Wallet):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.url = getenv("SPARK_URL")
|
self.url = getenv("SPARK_URL").replace("/rpc", "")
|
||||||
self.token = getenv("SPARK_TOKEN")
|
self.token = getenv("SPARK_TOKEN")
|
||||||
|
|
||||||
def __getattr__(self, key):
|
def __getattr__(self, key):
|
||||||
@@ -27,18 +29,43 @@ class SparkWallet(Wallet):
|
|||||||
params = args
|
params = args
|
||||||
elif kwargs:
|
elif kwargs:
|
||||||
params = 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:
|
try:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
except:
|
except:
|
||||||
raise UnknownError(r.text)
|
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"])
|
raise SparkError(data["message"])
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
return call
|
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(
|
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:
|
) -> InvoiceResponse:
|
||||||
@@ -65,11 +92,12 @@ class SparkWallet(Wallet):
|
|||||||
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||||
try:
|
try:
|
||||||
r = self.pay(bolt11)
|
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 exc:
|
||||||
except (SparkError, UnknownError) as e:
|
return PaymentResponse(False, None, 0, None, str(exc))
|
||||||
ok, checking_id, fee_msat, error_message = False, None, None, str(e)
|
|
||||||
|
|
||||||
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:
|
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
r = self.listinvoices(label=checking_id)
|
r = self.listinvoices(label=checking_id)
|
||||||
@@ -91,3 +119,21 @@ class SparkWallet(Wallet):
|
|||||||
return PaymentStatus(False)
|
return PaymentStatus(False)
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
raise KeyError("supplied an invalid checking_id")
|
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):
|
class VoidWallet(Wallet):
|
||||||
@@ -9,6 +9,12 @@ class VoidWallet(Wallet):
|
|||||||
) -> InvoiceResponse:
|
) -> InvoiceResponse:
|
||||||
raise Unsupported("")
|
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:
|
def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||||
raise Unsupported("")
|
raise Unsupported("")
|
||||||
|
|
||||||
@@ -17,3 +23,6 @@ class VoidWallet(Wallet):
|
|||||||
|
|
||||||
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
raise Unsupported("")
|
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
|
aiofiles==0.5.0
|
||||||
|
async-generator==1.10
|
||||||
|
attrs==20.2.0
|
||||||
bech32==1.2.0
|
bech32==1.2.0
|
||||||
bitstring==3.1.7
|
bitstring==3.1.7
|
||||||
blinker==1.4
|
blinker==1.4
|
||||||
brotli==1.0.9
|
brotli==1.0.9
|
||||||
cerberus==1.3.2
|
cerberus==1.3.2
|
||||||
certifi==2020.6.20
|
certifi==2020.6.20
|
||||||
chardet==3.0.4
|
|
||||||
click==7.1.2
|
click==7.1.2
|
||||||
ecdsa==0.16.0
|
ecdsa==0.16.0
|
||||||
environs==8.0.0
|
environs==8.0.0
|
||||||
h11==0.10.0
|
h11==0.11.0
|
||||||
h2==3.2.0
|
h2==4.0.0
|
||||||
hpack==3.0.0
|
hpack==4.0.0
|
||||||
hypercorn==0.10.2
|
httpcore==0.12.0
|
||||||
hyperframe==5.2.0
|
httpx==0.16.1
|
||||||
|
hypercorn==0.11.1
|
||||||
|
hyperframe==6.0.0
|
||||||
idna==2.10
|
idna==2.10
|
||||||
itsdangerous==1.1.0
|
itsdangerous==1.1.0
|
||||||
jinja2==2.11.2
|
jinja2==2.11.2
|
||||||
lnurl==0.3.5
|
lnurl==0.3.5
|
||||||
markupsafe==1.1.1
|
markupsafe==1.1.1
|
||||||
marshmallow==3.8.0
|
marshmallow==3.8.0
|
||||||
|
outcome==1.0.1
|
||||||
priority==1.3.0
|
priority==1.3.0
|
||||||
pydantic==1.6.1
|
pydantic==1.6.1
|
||||||
pyscss==1.3.7
|
pyscss==1.3.7
|
||||||
@@ -27,12 +31,15 @@ python-dotenv==0.14.0
|
|||||||
quart==0.13.1
|
quart==0.13.1
|
||||||
quart-compress==0.2.1
|
quart-compress==0.2.1
|
||||||
quart-cors==0.3.0
|
quart-cors==0.3.0
|
||||||
requests==2.24.0
|
quart-trio==0.5.1
|
||||||
|
rfc3986==1.4.0
|
||||||
secure==0.2.1
|
secure==0.2.1
|
||||||
shortuuid==1.0.1
|
shortuuid==1.0.1
|
||||||
six==1.15.0
|
six==1.15.0
|
||||||
|
sniffio==1.1.0
|
||||||
|
sortedcontainers==2.2.2
|
||||||
toml==0.10.1
|
toml==0.10.1
|
||||||
|
trio==0.17.0
|
||||||
typing-extensions==3.7.4.3
|
typing-extensions==3.7.4.3
|
||||||
urllib3==1.25.10
|
|
||||||
werkzeug==1.0.1
|
werkzeug==1.0.1
|
||||||
wsproto==0.15.0
|
wsproto==0.15.0
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from lnbits.app import create_app
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def client():
|
async def client():
|
||||||
app = create_app()
|
app = create_app()
|
||||||
app.config["TESTING"] = True
|
app.config["TESTING"] = True
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_homepage(client):
|
async def test_homepage(client):
|
||||||
r = await client.get("/")
|
r = await client.get("/")
|
||||||
assert b"Add a new wallet" in await r.get_data()
|
assert b"Add a new wallet" in await r.get_data()
|
||||||
|
|||||||
Reference in New Issue
Block a user