diff --git a/.env.example b/.env.example index 618155559..bfaccf65a 100644 --- a/.env.example +++ b/.env.example @@ -35,24 +35,21 @@ LNBITS_ADMIN_MACAROON=LNBITS_ADMIN_MACAROON # LndWallet LND_GRPC_ENDPOINT=127.0.0.1 LND_GRPC_PORT=11009 -LND_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert" -LND_ADMIN_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon" -LND_INVOICE_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/invoice.macaroon" -LND_READ_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/read.macaroon" +LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert" +LND_GRPC_ADMIN_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon" +LND_GRPC_INVOICE_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/invoice.macaroon" # LndRestWallet -LND_REST_ENDPOINT=https://localhost:8080/ +LND_REST_ENDPOINT=https://127.0.0.1:8080/ LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert" LND_REST_ADMIN_MACAROON="HEXSTRING" LND_REST_INVOICE_MACAROON="HEXSTRING" -LND_REST_READ_MACAROON="HEXSTRING" # LNPayWallet LNPAY_API_ENDPOINT=https://lnpay.co/v1/ LNPAY_API_KEY=LNPAY_API_KEY LNPAY_ADMIN_KEY=LNPAY_ADMIN_KEY LNPAY_INVOICE_KEY=LNPAY_INVOICE_KEY -LNPAY_READ_KEY=LNPAY_READ_KEY # LntxbotWallet LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/ diff --git a/Dockerfile b/Dockerfile index f959cbd51..9bd165a7c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,6 @@ FROM python:3.7-slim WORKDIR /app COPY requirements.txt /app/ RUN pip install --no-cache-dir -q -r requirements.txt -RUN pip install --no-cache-dir -q hypercorn COPY . /app EXPOSE 5000 diff --git a/Pipfile b/Pipfile index 0569db9e4..ca5cfe07a 100644 --- a/Pipfile +++ b/Pipfile @@ -21,10 +21,13 @@ quart-compress = "*" secure = "*" typing-extensions = "*" httpx = "*" +quart-trio = "*" +trio = "*" +hypercorn = {extras = ["trio"], version = "*"} [dev-packages] black = "==20.8b1" pytest = "*" pytest-cov = "*" -pytest-asyncio = "*" mypy = "==0.761" +pytest-trio = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 7653d4883..2358d8ad9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6f7d14aa2e3bc6a1319c7f0e2873151cefa741792fccc249567932a3a94263e3" + "sha256": "76a3823f58d720ea680fdcd246f2a8b5fa16ce0a87a650e5e9fff5559dca7309" }, "pipfile-spec": 6, "requires": { @@ -23,11 +23,28 @@ ], "version": "==0.5.0" }, + "async-generator": { + "hashes": [ + "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", + "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144" + ], + "markers": "python_version >= '3.5'", + "version": "==1.10" + }, + "attrs": { + "hashes": [ + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.2.0" + }, "bech32": { "hashes": [ "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899", "sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981" ], + "markers": "python_version >= '3.5'", "version": "==1.2.0" }, "bitstring": { @@ -101,6 +118,7 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "ecdsa": { @@ -131,6 +149,7 @@ "sha256:ac9e293a1990b339d5d71b19c5fe630e3dd4d768c620d1730d355485323f1b25", "sha256:bb7ac7099dd67a857ed52c815a6192b6b1f5ba6b516237fc24a085341340593d" ], + "markers": "python_full_version >= '3.6.1'", "version": "==4.0.0" }, "hpack": { @@ -138,6 +157,7 @@ "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c", "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095" ], + "markers": "python_full_version >= '3.6.1'", "version": "==4.0.0" }, "httpcore": { @@ -145,21 +165,26 @@ "sha256:72cfaa461dbdc262943ff4c9abf5b195391a03cdcc152e636adb4239b15e77e1", "sha256:a35dddd1f4cc34ff37788337ef507c0ad0276241ece6daf663ac9e77c0b87232" ], + "markers": "python_version >= '3.6'", "version": "==0.11.1" }, "httpx": { "hashes": [ - "sha256:4c81dbf98a29cb4f51f415140df56542f9d4860798d713e336642e953cddd1db", - "sha256:7b3c07bfdcdadd92020dd4c07b15932abdcf1c898422a4e98de3d19b2223310b" + "sha256:02326f2d3c61133db31e4b88dd3432479b434e52a68d813eab6db930f13611ea", + "sha256:254b371e3880a8e2387bf9ead6949bac797bd557fda26eba19a6153a0c06bd2b" ], "index": "pypi", - "version": "==0.15.4" + "version": "==0.15.5" }, "hypercorn": { + "extras": [ + "trio" + ], "hashes": [ "sha256:6540faeba9dd44f7e74c7cc1beae3a438a7efb5f77323d1199457da46d32c2c2", "sha256:b5c479023757e279f954b46a4ec9dd85e58a2bcbf4d959d5601cbced593e711d" ], + "index": "pypi", "version": "==0.11.0" }, "hyperframe": { @@ -167,6 +192,7 @@ "sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1", "sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34" ], + "markers": "python_full_version >= '3.6.1'", "version": "==6.0.0" }, "idna": { @@ -181,6 +207,7 @@ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "jinja2": { @@ -188,6 +215,7 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "lnurl": { @@ -233,6 +261,7 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "marshmallow": { @@ -240,8 +269,17 @@ "sha256:2272273505f1644580fbc66c6b220cc78f893eb31f1ecde2af98ad28011e9811", "sha256:47911dd7c641a27160f0df5fd0fe94667160ffe97f70a42c3cc18388d86098cc" ], + "markers": "python_version >= '3.5'", "version": "==3.8.0" }, + "outcome": { + "hashes": [ + "sha256:ee46c5ce42780cde85d55a61819d0e6b8cb490f1dbd749ba75ff2629771dcd2d", + "sha256:fc7822068ba7dd0fc2532743611e8a73246708d3564e29a39f93d6ab3701b66f" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.0.1" + }, "priority": { "hashes": [ "sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe", @@ -269,6 +307,7 @@ "sha256:f769141ab0abfadf3305d4fcf36660e5cf568a666dd3efab7c3d4782f70946b1", "sha256:f8af9b840a9074e08c0e6dc93101de84ba95df89b267bf7151d74c553d66833b" ], + "markers": "python_version >= '3.6'", "version": "==1.6.1" }, "pyscss": { @@ -309,6 +348,14 @@ "index": "pypi", "version": "==0.3.0" }, + "quart-trio": { + "hashes": [ + "sha256:00f3b20f8d82ce7e81ead61db4efba38ed7653c7e28199defded46b663ab2595", + "sha256:dafc8f0440d4b70fa60d24122a161d2373894d2bfa9f713d9f1df1fd508f0834" + ], + "index": "pypi", + "version": "==0.5.1" + }, "requests": { "hashes": [ "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", @@ -318,6 +365,9 @@ "version": "==2.24.0" }, "rfc3986": { + "extras": [ + "idna2008" + ], "hashes": [ "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d", "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50" @@ -345,6 +395,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "sniffio": { @@ -352,8 +403,16 @@ "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5", "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21" ], + "markers": "python_version >= '3.5'", "version": "==1.1.0" }, + "sortedcontainers": { + "hashes": [ + "sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba", + "sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f" + ], + "version": "==2.2.2" + }, "toml": { "hashes": [ "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", @@ -361,6 +420,14 @@ ], "version": "==0.10.1" }, + "trio": { + "hashes": [ + "sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d", + "sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291" + ], + "index": "pypi", + "version": "==0.17.0" + }, "typing-extensions": { "hashes": [ "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", @@ -375,6 +442,7 @@ "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": { @@ -382,6 +450,7 @@ "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.0.1" }, "wsproto": { @@ -389,6 +458,7 @@ "sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d", "sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d" ], + "markers": "python_full_version >= '3.6.1'", "version": "==0.15.0" } }, @@ -400,11 +470,20 @@ ], "version": "==1.4.4" }, + "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" }, "black": { @@ -419,6 +498,7 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "coverage": { @@ -458,8 +538,16 @@ "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==5.3" }, + "idna": { + "hashes": [ + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + ], + "version": "==2.10" + }, "iniconfig": { "hashes": [ "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", @@ -494,11 +582,20 @@ ], "version": "==0.4.3" }, + "outcome": { + "hashes": [ + "sha256:ee46c5ce42780cde85d55a61819d0e6b8cb490f1dbd749ba75ff2629771dcd2d", + "sha256:fc7822068ba7dd0fc2532743611e8a73246708d3564e29a39f93d6ab3701b66f" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.0.1" + }, "packaging": { "hashes": [ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pathspec": { @@ -513,6 +610,7 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "py": { @@ -520,6 +618,7 @@ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, "pyparsing": { @@ -527,23 +626,16 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { "hashes": [ - "sha256:1cd09785c0a50f9af72220dd12aa78cfa49cbffc356c61eab009ca189e018a33", - "sha256:d010e24666435b39a4cf48740b039885642b6c273a3f77be3e7e03554d2806b7" + "sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9", + "sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92" ], "index": "pypi", - "version": "==6.1.0" - }, - "pytest-asyncio": { - "hashes": [ - "sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d", - "sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700" - ], - "index": "pypi", - "version": "==0.14.0" + "version": "==6.1.1" }, "pytest-cov": { "hashes": [ @@ -553,6 +645,14 @@ "index": "pypi", "version": "==2.10.1" }, + "pytest-trio": { + "hashes": [ + "sha256:3f48cc1df66d279d705af38ad38d1639c2e2380ddffcdc3a45bb81758de61f03", + "sha256:9bf0a490fd177a33617e8709242293fae47934de2b51f8209eb2c0545b6ca8fe" + ], + "index": "pypi", + "version": "==0.6.0" + }, "regex": { "hashes": [ "sha256:088afc8c63e7bd187a3c70a94b9e50ab3f17e1d3f52a32750b5b77dbe99ef5ef", @@ -584,8 +684,24 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, + "sniffio": { + "hashes": [ + "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5", + "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21" + ], + "markers": "python_version >= '3.5'", + "version": "==1.1.0" + }, + "sortedcontainers": { + "hashes": [ + "sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba", + "sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f" + ], + "version": "==2.2.2" + }, "toml": { "hashes": [ "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", @@ -593,6 +709,14 @@ ], "version": "==0.10.1" }, + "trio": { + "hashes": [ + "sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d", + "sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291" + ], + "index": "pypi", + "version": "==0.17.0" + }, "typed-ast": { "hashes": [ "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", diff --git a/Procfile b/Procfile index 1274b8d06..c0ca4887a 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: hypercorn --bind 0.0.0.0:5000 'lnbits.app:create_app()' +web: hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()' diff --git a/docs/devs/installation.md b/docs/devs/installation.md index a48365a49..51a915814 100644 --- a/docs/devs/installation.md +++ b/docs/devs/installation.md @@ -34,7 +34,7 @@ You will need to copy `.env.example` to `.env`, then set variables there. ![Files](https://i.imgur.com/ri2zOe8.png) You might also need to install additional packages, depending on the [backend wallet](../guide/wallets.md) you use. -E.g. when you want to use LND you have to `pipenv run pip install lnd-grpc`. +E.g. when you want to use LND you have to `pipenv run pip install lndgrpc` and `pipenv run pip install pureprc`. Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment. diff --git a/docs/guide/installation.md b/docs/guide/installation.md index b7adb796b..b61ccf97c 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -18,7 +18,7 @@ python3 -m venv venv cp .env.example .env ./venv/bin/quart assets ./venv/bin/quart migrate -./venv/bin/hypercorn --bind 0.0.0.0:5000 'lnbits.app:create_app()' +./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()' ``` No you can visit your LNbits at http://localhost:5000/. @@ -31,5 +31,6 @@ You might also need to install additional packages, depending on the chosen back E.g. when you want to use LND you have to run: ```sh -./venv/bin/pip install lnd-grpc +./venv/bin/pip install lndgrpc +./venv/bin/pip install purerpc ``` diff --git a/docs/guide/wallets.md b/docs/guide/wallets.md index 986711fbb..bbad0e7a8 100644 --- a/docs/guide/wallets.md +++ b/docs/guide/wallets.md @@ -29,7 +29,7 @@ Using this wallet requires the installation of the `pylightning` Python package. ### LND (gRPC) -Using this wallet requires the installation of the `lnd-grpc` Python package. +Using this wallet requires the installation of the `lndgrpc` and `purerpc` Python packages. - `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet** - `LND_GRPC_ENDPOINT`: ip_address diff --git a/lnbits/app.py b/lnbits/app.py index 8184b04e9..64e286c54 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -1,24 +1,26 @@ +import trio # type: ignore import importlib -from quart import Quart, g +from quart import g +from quart_trio import QuartTrio from quart_cors import cors # type: ignore from quart_compress import Compress # type: ignore from secure import SecureHeaders # type: ignore from .commands import db_migrate, handle_assets from .core import core_app -from .db import open_db +from .db import open_db, open_ext_db from .helpers import get_valid_extensions, get_js_vendored, get_css_vendored, url_for_vendored from .proxy_fix import ASGIProxyFix secure_headers = SecureHeaders(hsts=False) -def create_app(config_object="lnbits.settings") -> Quart: +def create_app(config_object="lnbits.settings") -> QuartTrio: """Create application factory. :param config_object: The configuration object to use. """ - app = Quart(__name__, static_folder="static") + app = QuartTrio(__name__, static_folder="static") app.config.from_object(config_object) app.asgi_http_class = ASGIProxyFix @@ -30,29 +32,40 @@ def create_app(config_object="lnbits.settings") -> Quart: register_filters(app) register_commands(app) register_request_hooks(app) + register_async_tasks(app) return app -def register_blueprints(app: Quart) -> None: +def register_blueprints(app: QuartTrio) -> None: """Register Flask blueprints / LNbits extensions.""" app.register_blueprint(core_app) for ext in get_valid_extensions(): try: ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}") - app.register_blueprint(getattr(ext_module, f"{ext.code}_ext"), url_prefix=f"/{ext.code}") + bp = getattr(ext_module, f"{ext.code}_ext") + + @bp.before_request + async def before_request(): + g.ext_db = open_ext_db(ext.code) + + @bp.teardown_request + async def after_request(exc): + g.ext_db.__exit__(type(exc), exc, None) + + app.register_blueprint(bp, url_prefix=f"/{ext.code}") except Exception: raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.") -def register_commands(app: Quart): +def register_commands(app: QuartTrio): """Register Click commands.""" app.cli.add_command(db_migrate) app.cli.add_command(handle_assets) -def register_assets(app: Quart): +def register_assets(app: QuartTrio): """Serve each vendored asset separately or a bundle.""" @app.before_request @@ -65,13 +78,13 @@ def register_assets(app: Quart): g.VENDORED_CSS = ["/static/bundle.css"] -def register_filters(app: Quart): +def register_filters(app: QuartTrio): """Jinja filters.""" app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"] app.jinja_env.globals["EXTENSIONS"] = get_valid_extensions() -def register_request_hooks(app: Quart): +def register_request_hooks(app: QuartTrio): """Open the core db for each request so everything happens in a big transaction""" @app.before_request @@ -86,3 +99,20 @@ def register_request_hooks(app: Quart): @app.teardown_request async def after_request(exc): g.db.__exit__(type(exc), exc, None) + + +def register_async_tasks(app): + from lnbits.core.tasks import invoice_listener, webhook_handler + + @app.route("/wallet/webhook", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) + async def webhook_listener(): + return await webhook_handler() + + @app.before_serving + async def listeners(): + app.nursery.start_soon(invoice_listener) + print("started invoice_listener") + + @app.after_serving + async def stop_listeners(): + pass diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 6d19c904b..984492a5e 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -131,6 +131,19 @@ def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]: # --------------- +def get_standalone_payment(checking_id: str) -> Optional[Payment]: + row = g.db.fetchone( + """ + SELECT * + FROM apipayments + WHERE checking_id = ? + """, + (checking_id,), + ) + + return Payment.from_row(row) if row else None + + def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]: row = g.db.fetchone( """ diff --git a/lnbits/core/models.py b/lnbits/core/models.py index 24d764950..243f9342a 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -2,6 +2,8 @@ import json from typing import List, NamedTuple, Optional, Dict from sqlite3 import Row +from lnbits.settings import WALLET + class User(NamedTuple): id: str @@ -113,6 +115,17 @@ class Payment(NamedTuple): update_payment_status(self.checking_id, pending) + def check_pending(self) -> None: + if self.is_uncheckable: + return + + if self.is_out: + pending = WALLET.get_payment_status(self.checking_id) + else: + pending = WALLET.get_invoice_status(self.checking_id) + + self.set_pending(pending.pending) + def delete(self) -> None: from .crud import delete_payment diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index be48d8a63..bafc47406 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -1,11 +1,17 @@ -import asyncio -from typing import Optional, Awaitable -from quart import Quart, Request, g +import trio # type: ignore +from http import HTTPStatus +from typing import Optional, Tuple, List, Callable, Awaitable +from quart import Request, g +from quart_trio import QuartTrio from werkzeug.datastructures import Headers -from lnbits.db import open_db +from lnbits.db import open_db, open_ext_db +from lnbits.settings import WALLET -main_app: Optional[Quart] = None +from .models import Payment +from .crud import get_standalone_payment + +main_app: Optional[QuartTrio] = None def grab_app_for_later(state): @@ -13,21 +19,60 @@ def grab_app_for_later(state): main_app = state.app -def run_on_pseudo_request(awaitable: Awaitable): - async def run(awaitable): - fk = Request( - "GET", - "http", - "/background/pseudo", - b"", - Headers([("host", "lnbits.background")]), - "", - "1.1", - send_push_promise=lambda x, h: None, - ) - async with main_app.request_context(fk): - g.db = open_db() - await awaitable +async def send_push_promise(a, b) -> None: + pass - loop = asyncio.get_event_loop() - loop.create_task(run(awaitable)) + +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[Tuple[str, Callable[[Payment], Awaitable[None]]]] = [] + + +def register_invoice_listener(ext_name: str, cb: Callable[[Payment], Awaitable[None]]): + """ + A method intended for extensions to call when they want to be notified about + new invoice payments incoming. + """ + print(f"registering {ext_name} invoice_listener callback: {cb}") + invoice_listeners.append((ext_name, cb)) + + +async def webhook_handler(): + handler = getattr(WALLET, "webhook_listener", None) + if handler: + return await handler() + return "", HTTPStatus.NO_CONTENT + + +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 ext_name, cb in invoice_listeners: + with open_ext_db(ext_name) as g.ext_db: # type: ignore + await cb(payment) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index a4fefc4f4..72c23c771 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -7,7 +7,6 @@ from lnbits.core import core_app from lnbits.core.services import create_invoice, pay_invoice from lnbits.core.crud import delete_expired_invoices from lnbits.decorators import api_check_wallet_key, api_validate_post_request -from lnbits.settings import WALLET @core_app.route("/api/v1/wallet", methods=["GET"]) @@ -32,10 +31,7 @@ async def api_payments(): delete_expired_invoices() for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True): - if payment.is_out: - payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending) - else: - payment.set_pending(WALLET.get_invoice_status(payment.checking_id).pending) + payment.check_pending() return jsonify(g.wallet.get_payments(pending=True)), HTTPStatus.OK @@ -123,17 +119,8 @@ async def api_payment(payment_hash): return jsonify({"paid": True}), HTTPStatus.OK try: - if payment.is_uncheckable: - pass - elif payment.is_out: - is_paid = not WALLET.get_payment_status(payment.checking_id).pending - elif payment.is_in: - is_paid = not WALLET.get_invoice_status(payment.checking_id).pending + payment.check_pending() except Exception: return jsonify({"paid": False}), HTTPStatus.OK - if is_paid: - payment.set_pending(False) - return jsonify({"paid": True}), HTTPStatus.OK - - return jsonify({"paid": False}), HTTPStatus.OK + return jsonify({"paid": not payment.pending}), HTTPStatus.OK diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index b189e82ff..9ea459228 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -123,6 +123,6 @@ async def lnurlwallet(): user = get_user(account.id) wallet = create_wallet(user_id=user.id) - run_on_pseudo_request(redeem_lnurl_withdraw(wallet.id, withdraw_res, "LNbits initial funding: voucher redeem.")) + run_on_pseudo_request(redeem_lnurl_withdraw, wallet.id, withdraw_res, "LNbits initial funding: voucher redeem.") return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id)) diff --git a/lnbits/db.py b/lnbits/db.py index ec26d69b7..72d8660ab 100644 --- a/lnbits/db.py +++ b/lnbits/db.py @@ -10,20 +10,26 @@ class Database: self.connection = sqlite3.connect(db_path) self.connection.row_factory = sqlite3.Row self.cursor = self.connection.cursor() + self.closed = False def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): + if self.closed: + return + if exc_val: self.connection.rollback() self.cursor.close() - self.cursor.close() + self.connection.close() else: self.connection.commit() self.cursor.close() self.connection.close() + self.closed = True + def commit(self): self.connection.commit() diff --git a/lnbits/extensions/lnurlp/__init__.py b/lnbits/extensions/lnurlp/__init__.py index 4fb646608..2e3e16834 100644 --- a/lnbits/extensions/lnurlp/__init__.py +++ b/lnbits/extensions/lnurlp/__init__.py @@ -6,3 +6,9 @@ lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", te from .views_api import * # noqa from .views import * # noqa +from .lnurl import * # noqa +from .tasks import on_invoice_paid + +from lnbits.core.tasks import register_invoice_listener + +register_invoice_listener("lnurlp", on_invoice_paid) diff --git a/lnbits/extensions/lnurlp/crud.py b/lnbits/extensions/lnurlp/crud.py index b882afc09..adebb84ac 100644 --- a/lnbits/extensions/lnurlp/crud.py +++ b/lnbits/extensions/lnurlp/crud.py @@ -1,11 +1,12 @@ from typing import List, Optional, Union +from lnbits import bolt11 from lnbits.db import open_ext_db 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, amount: int, webhook_url: str) -> Optional[PayLink]: with open_ext_db("lnurlp") as db: db.execute( """ @@ -14,26 +15,36 @@ def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink description, amount, served_meta, - served_pr + served_pr, + webhook_url ) - VALUES (?, ?, ?, 0, 0) + VALUES (?, ?, ?, 0, 0, ?) """, - (wallet_id, description, amount), + (wallet_id, description, amount, webhook_url), ) link_id = db.cursor.lastrowid return get_pay_link(link_id) -def get_pay_link(link_id: str) -> Optional[PayLink]: +def get_pay_link(link_id: int) -> Optional[PayLink]: with open_ext_db("lnurlp") as db: row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) return PayLink.from_row(row) if row else None -def get_pay_link_by_hash(unique_hash: str) -> Optional[PayLink]: +def get_pay_link_by_invoice(payment_hash: str) -> Optional[PayLink]: + # this excludes invoices with webhooks that have been sent already + with open_ext_db("lnurlp") as db: - row = db.fetchone("SELECT * FROM pay_links WHERE unique_hash = ?", (unique_hash,)) + row = db.fetchone( + """ + SELECT pay_links.* FROM pay_links + INNER JOIN invoices ON invoices.pay_link = pay_links.id + WHERE payment_hash = ? AND webhook_sent IS NULL + """, + (payment_hash,), + ) return PayLink.from_row(row) if row else None @@ -49,7 +60,7 @@ def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: return [PayLink.from_row(row) for row in rows] -def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: +def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) with open_ext_db("lnurlp") as db: @@ -59,7 +70,7 @@ def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: return PayLink.from_row(row) if row else None -def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: +def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) with open_ext_db("lnurlp") as db: @@ -69,6 +80,30 @@ def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: return PayLink.from_row(row) if row else None -def delete_pay_link(link_id: str) -> None: +def delete_pay_link(link_id: int) -> None: with open_ext_db("lnurlp") as db: db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,)) + + +def save_link_invoice(link_id: int, payment_request: str) -> None: + inv = bolt11.decode(payment_request) + + with open_ext_db("lnurlp") as db: + db.execute( + """ + INSERT INTO invoices (pay_link, payment_hash, expiry) + VALUES (?, ?, ?) + """, + (link_id, inv.payment_hash, inv.expiry), + ) + + +def mark_webhook_sent(payment_hash: str, status: int) -> None: + with open_ext_db("lnurlp") as db: + db.execute( + """ + UPDATE invoices SET webhook_sent = ? + WHERE payment_hash = ? + """, + (status, payment_hash), + ) diff --git a/lnbits/extensions/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py new file mode 100644 index 000000000..5275927fc --- /dev/null +++ b/lnbits/extensions/lnurlp/lnurl.py @@ -0,0 +1,48 @@ +import hashlib +from http import HTTPStatus +from quart import jsonify, url_for +from lnurl import LnurlPayResponse, LnurlPayActionResponse + +from lnbits.core.services import create_invoice + +from . import lnurlp_ext +from .crud import increment_pay_link, save_link_invoice + + +@lnurlp_ext.route("/api/v1/lnurl/", 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 + + url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True) + + resp = LnurlPayResponse( + callback=url, + min_sendable=link.amount * 1000, + max_sendable=link.amount * 1000, + metadata=link.lnurlpay_metadata, + ) + + return jsonify(resp.dict()), HTTPStatus.OK + + +@lnurlp_ext.route("/api/v1/lnurl/cb/", 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"}, + ) + + save_link_invoice(link_id, payment_request) + + resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[]) + + return jsonify(resp.dict()), HTTPStatus.OK diff --git a/lnbits/extensions/lnurlp/migrations.py b/lnbits/extensions/lnurlp/migrations.py index cdb8e9a67..d9c61d360 100644 --- a/lnbits/extensions/lnurlp/migrations.py +++ b/lnbits/extensions/lnurlp/migrations.py @@ -14,3 +14,22 @@ 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 + ); + """ + ) diff --git a/lnbits/extensions/lnurlp/models.py b/lnbits/extensions/lnurlp/models.py index 6cac5af90..e376cf752 100644 --- a/lnbits/extensions/lnurlp/models.py +++ b/lnbits/extensions/lnurlp/models.py @@ -7,12 +7,15 @@ from typing import NamedTuple class PayLink(NamedTuple): - id: str + id: int wallet: str description: str amount: int served_meta: int served_pr: int + webhook_url: str + success_text: str + success_url: str @classmethod def from_row(cls, row: Row) -> "PayLink": @@ -27,3 +30,9 @@ class PayLink(NamedTuple): @property def lnurlpay_metadata(self) -> LnurlPayMetadata: return LnurlPayMetadata(json.dumps([["text/plain", self.description]])) + + +class Invoice(NamedTuple): + payment_hash: str + link_id: int + webhook_sent: bool diff --git a/lnbits/extensions/lnurlp/tasks.py b/lnbits/extensions/lnurlp/tasks.py new file mode 100644 index 000000000..49fb61b00 --- /dev/null +++ b/lnbits/extensions/lnurlp/tasks.py @@ -0,0 +1,31 @@ +import httpx + +from lnbits.core.models import Payment + +from .crud import get_pay_link_by_invoice, mark_webhook_sent + + +async def on_invoice_paid(payment: Payment) -> None: + print(payment) + islnurlp = "lnurlp" == payment.extra.get("tag") + if islnurlp: + pay_link = get_pay_link_by_invoice(payment.payment_hash) + if not pay_link: + # no pay_link or this webhook has already been sent + return + if 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, + "lnurlp": pay_link.id, + }, + timeout=40, + ) + mark_webhook_sent(payment.payment_hash, r.status_code) + except httpx.RequestError: + mark_webhook_sent(payment.payment_hash, -1) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html index 8a7206ddc..324df67b2 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -131,6 +131,13 @@ type="number" label="Amount (sat) *" > +
Create pay link @@ -174,6 +182,7 @@

ID: {{ qrCodeDialog.data.id }}
Amount: {{ qrCodeDialog.data.amount }} sat
+ Webhook: {{ qrCodeDialog.data.webhook_url }}

{% endraw %}
@@ -248,6 +257,12 @@ align: 'right', label: 'Amount (sat)', field: 'amount' + }, + { + name: 'webhook_url', + align: 'left', + label: 'Webhook URL', + field: 'webhook_url' } ], pagination: { @@ -331,7 +346,7 @@ 'PUT', '/lnurlp/api/v1/links/' + data.id, wallet.adminkey, - _.pick(data, 'description', 'amount') + _.pick(data, 'description', 'amount', 'webhook_url') ) .then(function (response) { self.payLinks = _.reject(self.payLinks, function (obj) { diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py index 852a68e4d..e54854750 100644 --- a/lnbits/extensions/lnurlp/views_api.py +++ b/lnbits/extensions/lnurlp/views_api.py @@ -1,11 +1,8 @@ -import hashlib -from quart import g, jsonify, request, url_for +from quart import g, jsonify, request from http import HTTPStatus -from lnurl import LnurlPayResponse, LnurlPayActionResponse from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl from lnbits.core.crud import get_user -from lnbits.core.services import create_invoice from lnbits.decorators import api_check_wallet_key, api_validate_post_request from lnbits.extensions.lnurlp import lnurlp_ext @@ -14,7 +11,6 @@ from .crud import ( get_pay_link, get_pay_links, update_pay_link, - increment_pay_link, delete_pay_link, ) @@ -60,6 +56,7 @@ async def api_link_retrieve(link_id): schema={ "description": {"type": "string", "empty": False, "required": True}, "amount": {"type": "integer", "min": 1, "required": True}, + "webhook_url": {"type": "string", "required": False}, } ) async def api_link_create_or_update(link_id=None): @@ -93,39 +90,3 @@ async def api_link_delete(link_id): delete_pay_link(link_id) return "", HTTPStatus.NO_CONTENT - - -@lnurlp_ext.route("/api/v1/lnurl/", 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 - - url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True) - - resp = LnurlPayResponse( - callback=url, - min_sendable=link.amount * 1000, - max_sendable=link.amount * 1000, - metadata=link.lnurlpay_metadata, - ) - - return jsonify(resp.dict()), HTTPStatus.OK - - -@lnurlp_ext.route("/api/v1/lnurl/cb/", 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 diff --git a/lnbits/proxy_fix.py b/lnbits/proxy_fix.py index 9b77dc17e..ec2a85b1a 100644 --- a/lnbits/proxy_fix.py +++ b/lnbits/proxy_fix.py @@ -5,10 +5,10 @@ from urllib.parse import urlparse from werkzeug.datastructures import Headers from quart import Request -from quart.asgi import ASGIHTTPConnection +from quart_trio.asgi import TrioASGIHTTPConnection -class ASGIProxyFix(ASGIHTTPConnection): +class ASGIProxyFix(TrioASGIHTTPConnection): def _create_request_from_scope(self, send: Callable) -> Request: headers = Headers() headers["Remote-Addr"] = (self.scope.get("client") or [""])[0] diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index 06c3979b7..126283eec 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import NamedTuple, Optional +from typing import NamedTuple, Optional, AsyncGenerator class InvoiceResponse(NamedTuple): @@ -43,6 +43,15 @@ class Wallet(ABC): def get_payment_status(self, checking_id: str) -> PaymentStatus: pass + @abstractmethod + def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + """ + this is an async function, but here it is noted without the 'async' + prefix because mypy has a bug identifying the signature of abstract + methods. + """ + pass + class Unsupported(Exception): pass diff --git a/lnbits/wallets/clightning.py b/lnbits/wallets/clightning.py index a8e4b1f23..852db3524 100644 --- a/lnbits/wallets/clightning.py +++ b/lnbits/wallets/clightning.py @@ -1,12 +1,14 @@ try: - from lightning import LightningRpc # type: ignore + from lightning import LightningRpc, RpcError # type: ignore except ImportError: # pragma: nocover LightningRpc = None +import trio # type: ignore import random +import json from os import getenv -from typing import Optional +from typing import Optional, AsyncGenerator from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported @@ -15,34 +17,63 @@ class CLightningWallet(Wallet): if LightningRpc is None: # pragma: nocover raise ImportError("The `pylightning` library must be installed to use `CLightningWallet`.") - self.l1 = LightningRpc(getenv("CLIGHTNING_RPC")) + self.rpc = getenv("CLIGHTNING_RPC") + self.ln = LightningRpc(self.rpc) + + # check description_hash support (could be provided by a plugin) + self.supports_description_hash = False + try: + answer = self.ln.help("invoicewithdescriptionhash") + if answer["help"][0]["command"].startswith( + "invoicewithdescriptionhash msatoshi label description_hash", + ): + self.supports_description_hash = True + except: + pass + + # check last payindex so we can listen from that point on + self.last_pay_index = 0 + invoices = self.ln.listinvoices() + for inv in invoices["invoices"][::-1]: + if "pay_index" in inv: + self.last_pay_index = inv["pay_index"] + break def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None ) -> InvoiceResponse: - if description_hash: - raise Unsupported("description_hash") - label = "lbl{}".format(random.random()) - r = self.l1.invoice(amount * 1000, label, memo, exposeprivatechannels=True) - ok, checking_id, payment_request, error_message = True, r["payment_hash"], r["bolt11"], None - return InvoiceResponse(ok, checking_id, payment_request, error_message) + msat = amount * 1000 + + try: + if description_hash: + if not self.supports_description_hash: + raise Unsupported("description_hash") + + params = [msat, label, description_hash.hex()] + r = self.ln.call("invoicewithdescriptionhash", params) + return InvoiceResponse(True, label, r["bolt11"], "") + else: + r = self.ln.invoice(msat, label, memo, exposeprivatechannels=True) + return InvoiceResponse(True, label, r["bolt11"], "") + except RpcError as exc: + error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." + return InvoiceResponse(False, label, None, error_message) def pay_invoice(self, bolt11: str) -> PaymentResponse: - r = self.l1.pay(bolt11) - ok, checking_id, fee_msat, error_message = True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None - return PaymentResponse(ok, checking_id, fee_msat, error_message) + r = self.ln.pay(bolt11) + return PaymentResponse(True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None) def get_invoice_status(self, checking_id: str) -> PaymentStatus: - r = self.l1.listinvoices(checking_id) + r = self.ln.listinvoices(checking_id) if not r["invoices"]: return PaymentStatus(False) if r["invoices"][0]["label"] == checking_id: - return PaymentStatus(r["pays"][0]["status"] == "paid") + return PaymentStatus(r["invoices"][0]["status"] == "paid") raise KeyError("supplied an invalid checking_id") def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = self.l1.listpays(payment_hash=checking_id) + r = self.ln.listpays(payment_hash=checking_id) if not r["pays"]: return PaymentStatus(False) if r["pays"][0]["payment_hash"] == checking_id: @@ -53,3 +84,27 @@ class CLightningWallet(Wallet): return PaymentStatus(False) return PaymentStatus(None) raise KeyError("supplied an invalid checking_id") + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + stream = await trio.open_unix_socket(self.rpc) + + i = 0 + while True: + call = json.dumps( + { + "method": "waitanyinvoice", + "id": 0, + "params": [self.last_pay_index], + } + ) + + await stream.send_all(call.encode("utf-8")) + + data = await stream.receive_some() + paid = json.loads(data.decode("ascii")) + + paid = self.ln.waitanyinvoice(self.last_pay_index) + self.last_pay_index = paid["pay_index"] + yield paid["label"] + + i += 1 diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py index d7dfca0ff..4a470fe0d 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -1,5 +1,6 @@ +import trio # type: ignore 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 @@ -64,3 +65,8 @@ class LNbitsWallet(Wallet): return PaymentStatus(None) return PaymentStatus(r.json()["paid"]) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + print("lnbits does not support paid invoices stream yet") + await trio.sleep(5) + yield "" diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py index a2b836fa8..1a084b6ea 100644 --- a/lnbits/wallets/lndgrpc.py +++ b/lnbits/wallets/lndgrpc.py @@ -1,92 +1,181 @@ try: - import lnd_grpc # type: ignore + import lndgrpc # type: ignore + from lndgrpc.common import ln # type: ignore except ImportError: # pragma: nocover - lnd_grpc = None + lndgrpc = None +try: + import purerpc # type: ignore +except ImportError: # pragma: nocover + purerpc = None + +import binascii import base64 +import hashlib from os import getenv -from typing import Optional, Dict +from typing import Optional, Dict, AsyncGenerator from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +def parse_checking_id(checking_id: str) -> bytes: + return base64.b64decode( + checking_id.replace("_", "/"), + ) + + +def stringify_checking_id(r_hash: bytes) -> str: + return ( + base64.b64encode( + r_hash, + ) + .decode("utf-8") + .replace("/", "_") + ) + + class LndWallet(Wallet): def __init__(self): - if lnd_grpc is None: # pragma: nocover - raise ImportError("The `lnd-grpc` library must be installed to use `LndWallet`.") + if lndgrpc is None: # pragma: nocover + raise ImportError("The `lndgrpc` library must be installed to use `LndWallet`.") + + if purerpc is None: # pragma: nocover + raise ImportError("The `purerpc` library must be installed to use `LndWallet`.") endpoint = getenv("LND_GRPC_ENDPOINT") self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint - self.port = getenv("LND_GRPC_PORT") - self.auth_admin = getenv("LND_ADMIN_MACAROON") - self.auth_invoice = getenv("LND_INVOICE_MACAROON") - self.auth_read = getenv("LND_READ_MACAROON") - self.auth_cert = getenv("LND_CERT") + self.port = int(getenv("LND_GRPC_PORT")) + self.cert_path = getenv("LND_GRPC_CERT") or getenv("LND_CERT") + self.auth_admin = getenv("LND_GRPC_ADMIN_MACAROON") or getenv("LND_ADMIN_MACAROON") + self.auth_invoices = getenv("LND_GRPC_INVOICE_MACAROON") or getenv("LND_INVOICE_MACAROON") + network = getenv("LND_GRPC_NETWORK", "mainnet") + + self.admin_rpc = lndgrpc.LNDClient( + f"{self.endpoint}:{self.port}", + cert_filepath=self.cert_path, + network=network, + macaroon_filepath=self.auth_admin, + ) + + self.invoices_rpc = lndgrpc.LNDClient( + f"{self.endpoint}:{self.port}", + cert_filepath=self.cert_path, + network=network, + macaroon_filepath=self.auth_invoices, + ) def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None ) -> InvoiceResponse: - lnd_rpc = lnd_grpc.Client( - lnd_dir=None, - macaroon_path=self.auth_invoice, - tls_cert_path=self.auth_cert, - network="mainnet", - grpc_host=self.endpoint, - grpc_port=self.port, - ) - params: Dict = {"value": amount, "expiry": 600, "private": True} + if description_hash: params["description_hash"] = description_hash # as bytes directly else: params["memo"] = memo or "" - lndResponse = lnd_rpc.add_invoice(**params) - decoded_hash = base64.b64encode(lndResponse.r_hash).decode("utf-8").replace("/", "_") - ok, checking_id, payment_request, error_message = True, decoded_hash, str(lndResponse.payment_request), None - return InvoiceResponse(ok, checking_id, payment_request, error_message) + + try: + req = ln.Invoice(**params) + resp = self.invoices_rpc._ln_stub.AddInvoice(req) + except Exception as exc: + error_message = str(exc) + return InvoiceResponse(False, None, None, error_message) + + checking_id = stringify_checking_id(resp.r_hash) + payment_request = str(resp.payment_request) + return InvoiceResponse(True, checking_id, payment_request, None) def pay_invoice(self, bolt11: str) -> PaymentResponse: - lnd_rpc = lnd_grpc.Client( - lnd_dir=None, - macaroon_path=self.auth_admin, - tls_cert_path=self.auth_cert, - network="mainnet", - grpc_host=self.endpoint, - grpc_port=self.port, - ) + resp = self.admin_rpc.send_payment(payment_request=bolt11) - payinvoice = lnd_rpc.pay_invoice( - payment_request=bolt11, - ) + if resp.payment_error: + return PaymentResponse(False, "", 0, resp.payment_error) - ok, checking_id, fee_msat, error_message = True, None, 0, None - - if payinvoice.payment_error: - ok, error_message = False, payinvoice.payment_error - else: - checking_id = base64.b64encode(payinvoice.payment_hash).decode("utf-8").replace("/", "_") - - return PaymentResponse(ok, checking_id, fee_msat, error_message) + r_hash = hashlib.sha256(resp.payment_preimage).digest() + checking_id = stringify_checking_id(r_hash) + return PaymentResponse(True, checking_id, 0, None) def get_invoice_status(self, checking_id: str) -> PaymentStatus: + try: + r_hash = parse_checking_id(checking_id) + if len(r_hash) != 32: + raise binascii.Error + except binascii.Error: + # this may happen if we switch between backend wallets + # that use different checking_id formats + return PaymentStatus(None) - check_id = base64.b64decode(checking_id.replace("_", "/")) - print(check_id) - lnd_rpc = lnd_grpc.Client( - lnd_dir=None, - macaroon_path=self.auth_invoice, - tls_cert_path=self.auth_cert, - network="mainnet", - grpc_host=self.endpoint, - grpc_port=self.port, - ) - - for _response in lnd_rpc.subscribe_single_invoice(check_id): - if _response.state == 1: - return PaymentStatus(True) + resp = self.invoices_rpc.lookup_invoice(r_hash.hex()) + if resp.settled: + return PaymentStatus(True) return PaymentStatus(None) def get_payment_status(self, checking_id: str) -> PaymentStatus: - return PaymentStatus(True) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + async with purerpc.secure_channel( + self.endpoint, + self.port, + get_ssl_context(self.cert_path), + ) as channel: + client = purerpc.Client("lnrpc.Lightning", channel) + subscribe_invoices = client.get_method_stub( + "SubscribeInvoices", + purerpc.RPCSignature( + purerpc.Cardinality.UNARY_STREAM, + ln.InvoiceSubscription, + ln.Invoice, + ), + ) + macaroon = load_macaroon(self.auth_admin) + + 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 + + +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() diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index a76a8995f..be4e4e5c7 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -1,7 +1,9 @@ -from os import getenv -from typing import Optional, Dict +import httpx +import json import base64 -from requests import get, post +from os import getenv +from typing import Optional, Dict, AsyncGenerator + from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet @@ -11,11 +13,16 @@ class LndRestWallet(Wallet): def __init__(self): endpoint = getenv("LND_REST_ENDPOINT") - self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint - print(self.endpoint) - self.auth_admin = {"Grpc-Metadata-macaroon": getenv("LND_REST_ADMIN_MACAROON")} - self.auth_invoice = {"Grpc-Metadata-macaroon": getenv("LND_REST_INVOICE_MACAROON")} - self.auth_read = {"Grpc-Metadata-macaroon": getenv("LND_REST_READ_MACAROON")} + endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint + endpoint = "https://" + endpoint if not endpoint.startswith("http") else endpoint + self.endpoint = endpoint + + self.auth_admin = { + "Grpc-Metadata-macaroon": getenv("LND_ADMIN_MACAROON") or getenv("LND_REST_ADMIN_MACAROON"), + } + self.auth_invoice = { + "Grpc-Metadata-macaroon": getenv("LND_INVOICE_MACAROON") or getenv("LND_REST_INVOICE_MACAROON") + } self.auth_cert = getenv("LND_REST_CERT") def create_invoice( @@ -30,84 +37,93 @@ class LndRestWallet(Wallet): else: data["memo"] = memo or "" - r = post( + r = httpx.post( url=f"{self.endpoint}/v1/invoices", headers=self.auth_invoice, verify=self.auth_cert, json=data, ) - ok, checking_id, payment_request, error_message = r.ok, None, None, None + if r.is_error: + error_message = r.text + try: + error_message = r.json()["error"] + except Exception: + pass + return InvoiceResponse(False, None, None, error_message) - if r.ok: - data = r.json() - payment_request = data["payment_request"] + data = r.json() + payment_request = data["payment_request"] + payment_hash = base64.b64decode(data["r_hash"]).hex() + checking_id = payment_hash - r = get( - url=f"{self.endpoint}/v1/payreq/{payment_request}", - headers=self.auth_read, - verify=self.auth_cert, - ) - print(r) - if r.ok: - checking_id = r.json()["payment_hash"].replace("/", "_") - print(checking_id) - error_message = None - ok = True - - return InvoiceResponse(ok, checking_id, payment_request, error_message) + return InvoiceResponse(True, checking_id, payment_request, None) def pay_invoice(self, bolt11: str) -> PaymentResponse: - r = post( + r = httpx.post( url=f"{self.endpoint}/v1/channels/transactions", headers=self.auth_admin, verify=self.auth_cert, json={"payment_request": bolt11}, ) - ok, checking_id, fee_msat, error_message = r.ok, None, 0, None - r = get( - url=f"{self.endpoint}/v1/payreq/{bolt11}", - headers=self.auth_admin, - verify=self.auth_cert, - ) - if r.ok: - checking_id = r.json()["payment_hash"] - else: - error_message = r.json()["error"] + if r.is_error: + error_message = r.text + try: + error_message = r.json()["error"] + except: + pass + return PaymentResponse(False, None, 0, error_message) - return PaymentResponse(ok, checking_id, fee_msat, error_message) + payment_hash = r.json()["payment_hash"] + checking_id = payment_hash + + return PaymentResponse(True, checking_id, 0, None) def get_invoice_status(self, checking_id: str) -> PaymentStatus: checking_id = checking_id.replace("_", "/") - print(checking_id) - r = get( + r = httpx.get( url=f"{self.endpoint}/v1/invoice/{checking_id}", headers=self.auth_invoice, verify=self.auth_cert, ) - print(r.json()["settled"]) if not r or r.json()["settled"] == False: return PaymentStatus(None) return PaymentStatus(r.json()["settled"]) def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = get( + r = httpx.get( url=f"{self.endpoint}/v1/payments", headers=self.auth_admin, verify=self.auth_cert, params={"include_incomplete": "True", "max_payments": "20"}, ) - if not r.ok: + if r.is_error: return PaymentStatus(None) payments = [p for p in r.json()["payments"] if p["payment_hash"] == checking_id] - print(checking_id) payment = payments[0] if payments else None - # check payment.status: https://api.lightning.community/rest/index.html?python#peersynctype + # check payment.status: + # https://api.lightning.community/rest/index.html?python#peersynctype statuses = {"UNKNOWN": None, "IN_FLIGHT": None, "SUCCEEDED": True, "FAILED": False} return PaymentStatus(statuses[payment["status"]]) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + url = self.endpoint + "/v1/invoices/subscribe" + + async with httpx.AsyncClient(timeout=None, headers=self.auth_admin, verify=self.auth_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 diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index db0d41718..782615b5c 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -1,6 +1,10 @@ +import json +import trio # type: ignore +import httpx from os import getenv -from typing import Optional, Dict -from requests import get, post +from http import HTTPStatus +from typing import Optional, Dict, AsyncGenerator +from quart import request from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet @@ -9,15 +13,16 @@ class LNPayWallet(Wallet): """https://docs.lnpay.co/""" def __init__(self): - endpoint = getenv("LNPAY_API_ENDPOINT") + endpoint = getenv("LNPAY_API_ENDPOINT", "https://lnpay.co/v1") self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint self.auth_admin = getenv("LNPAY_ADMIN_KEY") - self.auth_invoice = getenv("LNPAY_INVOICE_KEY") - self.auth_read = getenv("LNPAY_READ_KEY") self.auth_api = {"X-Api-Key": getenv("LNPAY_API_KEY")} def create_invoice( - self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, ) -> InvoiceResponse: data: Dict = {"num_satoshis": f"{amount}"} if description_hash: @@ -25,12 +30,17 @@ class LNPayWallet(Wallet): else: data["memo"] = memo or "" - r = post( - url=f"{self.endpoint}/user/wallet/{self.auth_invoice}/invoice", + r = httpx.post( + url=f"{self.endpoint}/user/wallet/{self.auth_admin}/invoice", headers=self.auth_api, json=data, ) - ok, checking_id, payment_request, error_message = r.status_code == 201, None, None, r.text + ok, checking_id, payment_request, error_message = ( + r.status_code == 201, + None, + None, + r.text, + ) if ok: data = r.json() @@ -39,7 +49,7 @@ class LNPayWallet(Wallet): return InvoiceResponse(ok, checking_id, payment_request, error_message) def pay_invoice(self, bolt11: str) -> PaymentResponse: - r = post( + r = httpx.post( url=f"{self.endpoint}/user/wallet/{self.auth_admin}/withdraw", headers=self.auth_api, json={"payment_request": bolt11}, @@ -55,10 +65,36 @@ class LNPayWallet(Wallet): return self.get_payment_status(checking_id) def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = get(url=f"{self.endpoint}/user/lntx/{checking_id}", headers=self.auth_api) + r = httpx.get( + url=f"{self.endpoint}/user/lntx/{checking_id}?fields=settled", + headers=self.auth_api, + ) - if not r.ok: + if r.is_error: return PaymentStatus(None) statuses = {0: None, 1: True, -1: False} return PaymentStatus(statuses[r.json()["settled"]]) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + self.send, receive = trio.open_memory_channel(0) + async for value in receive: + yield value + + async def webhook_listener(self): + text: str = await request.get_data() + data = json.loads(text) + 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}/user/lntx/{lntx_id}?fields=settled", + headers=self.auth_api, + ) + data = r.json() + if data["settled"]: + await self.send.send(lntx_id) + + return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py index 0c419da9d..1bbcd4d40 100644 --- a/lnbits/wallets/lntxbot.py +++ b/lnbits/wallets/lntxbot.py @@ -1,5 +1,6 @@ +import trio # type: ignore 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 @@ -75,3 +76,8 @@ class LntxbotWallet(Wallet): statuses = {"complete": True, "failed": False, "pending": None, "unknown": None} return PaymentStatus(statuses[r.json().get("status", "unknown")]) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + print("lntxbot does not support paid invoices stream yet") + await trio.sleep(5) + yield "" diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py index 49307b733..497afd2cb 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -1,6 +1,11 @@ +import json +import trio # type: ignore +import hmac +import httpx +from http import HTTPStatus from os import getenv -from typing import Optional -from requests import get, post +from typing import Optional, AsyncGenerator +from quart import request, url_for from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported @@ -20,47 +25,77 @@ class OpenNodeWallet(Wallet): if description_hash: raise Unsupported("description_hash") - r = post( - url=f"{self.endpoint}/v1/charges", + r = httpx.post( + f"{self.endpoint}/v1/charges", headers=self.auth_invoice, - json={"amount": f"{amount}", "description": memo}, # , "private": True}, + json={ + "amount": amount, + "description": memo or "", + "callback_url": url_for("webhook_listener", _external=True), + }, ) - ok, checking_id, payment_request, error_message = r.ok, None, None, None - if r.ok: - data = r.json()["data"] - checking_id, payment_request = data["id"], data["lightning_invoice"]["payreq"] - else: + if r.is_error: error_message = r.json()["message"] + return InvoiceResponse(False, None, None, error_message) - return InvoiceResponse(ok, checking_id, payment_request, error_message) + data = r.json()["data"] + checking_id = data["id"] + payment_request = data["lightning_invoice"]["payreq"] + return InvoiceResponse(True, checking_id, payment_request, None) def pay_invoice(self, bolt11: str) -> PaymentResponse: - r = post(url=f"{self.endpoint}/v2/withdrawals", headers=self.auth_admin, json={"type": "ln", "address": bolt11}) - ok, checking_id, fee_msat, error_message = r.ok, None, 0, None + r = httpx.post( + f"{self.endpoint}/v2/withdrawals", headers=self.auth_admin, json={"type": "ln", "address": bolt11} + ) - if r.ok: - data = r.json()["data"] - checking_id, fee_msat = data["id"], data["fee"] * 1000 - else: + if r.is_error: error_message = r.json()["message"] + return PaymentResponse(False, None, 0, 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) 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_invoice) - if not r.ok: + if r.is_error: return PaymentStatus(None) statuses = {"processing": None, "paid": True, "unpaid": False} return PaymentStatus(statuses[r.json()["data"]["status"]]) def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = get(url=f"{self.endpoint}/v1/withdrawal/{checking_id}", headers=self.auth_admin) + r = httpx.get(f"{self.endpoint}/v1/withdrawal/{checking_id}", headers=self.auth_admin) - if not r.ok: + if r.is_error: return PaymentStatus(None) statuses = {"initial": None, "pending": None, "confirmed": True, "error": False, "failed": False} return PaymentStatus(statuses[r.json()["data"]["status"]]) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + self.send, receive = trio.open_memory_channel(0) + async for value in receive: + yield value + + async def webhook_listener(self): + text: str = await request.get_data() + data = json.loads(text) + if type(data) is not dict or "event" not in data or data["event"].get("name") != "wallet_receive": + return "", HTTPStatus.NO_CONTENT + + charge_id = data["id"] + if data["status"] != "paid": + return "", HTTPStatus.NO_CONTENT + + x = hmac.new(self.auth_invoice["Authorization"], digestmod="sha256") + x.update(charge_id) + 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 diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index 20216343d..eaca0728c 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -1,7 +1,8 @@ import random -import requests +import json +import httpx from os import getenv -from typing import Optional +from typing import Optional, AsyncGenerator from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet @@ -16,7 +17,7 @@ class UnknownError(Exception): class SparkWallet(Wallet): def __init__(self): - self.url = getenv("SPARK_URL") + self.url = getenv("SPARK_URL").replace("/rpc", "") self.token = getenv("SPARK_TOKEN") def __getattr__(self, key): @@ -28,12 +29,12 @@ class SparkWallet(Wallet): elif kwargs: params = kwargs - 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}) try: data = r.json() except: raise UnknownError(r.text) - if not r.ok: + if r.is_error: raise SparkError(data["message"]) return data @@ -91,3 +92,14 @@ class SparkWallet(Wallet): return PaymentStatus(False) return PaymentStatus(None) raise KeyError("supplied an invalid checking_id") + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + url = self.url + "/stream?access-key=" + self.token + + 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"] diff --git a/lnbits/wallets/void.py b/lnbits/wallets/void.py index 49f1eaee0..044d7efaa 100644 --- a/lnbits/wallets/void.py +++ b/lnbits/wallets/void.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, AsyncGenerator from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported @@ -17,3 +17,6 @@ class VoidWallet(Wallet): def get_payment_status(self, checking_id: str) -> PaymentStatus: raise Unsupported("") + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + yield "" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..5f4a13a22 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +trio_mode = true diff --git a/requirements.txt b/requirements.txt index b62cf6577..30136a082 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ aiofiles==0.5.0 +async-generator==1.10 +attrs==20.2.0 bech32==1.2.0 bitstring==3.1.7 blinker==1.4 @@ -13,7 +15,7 @@ h11==0.9.0 h2==4.0.0 hpack==4.0.0 httpcore==0.11.1 -httpx==0.15.4 +httpx==0.15.5 hypercorn==0.11.0 hyperframe==6.0.0 idna==2.10 @@ -22,6 +24,7 @@ jinja2==2.11.2 lnurl==0.3.5 markupsafe==1.1.1 marshmallow==3.8.0 +outcome==1.0.1 priority==1.3.0 pydantic==1.6.1 pyscss==1.3.7 @@ -29,13 +32,16 @@ python-dotenv==0.14.0 quart==0.13.1 quart-compress==0.2.1 quart-cors==0.3.0 +quart-trio==0.5.1 requests==2.24.0 rfc3986==1.4.0 secure==0.2.1 shortuuid==1.0.1 six==1.15.0 sniffio==1.1.0 +sortedcontainers==2.2.2 toml==0.10.1 +trio==0.17.0 typing-extensions==3.7.4.3 urllib3==1.25.10 werkzeug==1.0.1 diff --git a/tests/conftest.py b/tests/conftest.py index e56006aff..7944fc014 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,6 @@ from lnbits.app import create_app @pytest.fixture -@pytest.mark.asyncio async def client(): app = create_app() app.config["TESTING"] = True diff --git a/tests/core/test_views.py b/tests/core/test_views.py index 422b25170..5bdde5814 100644 --- a/tests/core/test_views.py +++ b/tests/core/test_views.py @@ -1,7 +1,6 @@ import pytest -@pytest.mark.asyncio async def test_homepage(client): r = await client.get("/") assert b"Add a new wallet" in await r.get_data()