Merge pull request #101 from lnbits/invoice-listeners

This commit is contained in:
fiatjaf
2020-10-05 13:40:31 -03:00
committed by GitHub
38 changed files with 913 additions and 298 deletions

View File

@@ -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/

View File

@@ -3,7 +3,6 @@ FROM python:3.7-slim
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -q -r requirements.txt
RUN pip install --no-cache-dir -q hypercorn
COPY . /app
EXPOSE 5000

View File

@@ -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 = "*"

154
Pipfile.lock generated
View File

@@ -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",

View File

@@ -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()'

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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

View File

@@ -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

View File

@@ -131,6 +131,19 @@ def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
# ---------------
def get_standalone_payment(checking_id: str) -> Optional[Payment]:
row = g.db.fetchone(
"""
SELECT *
FROM apipayments
WHERE checking_id = ?
""",
(checking_id,),
)
return Payment.from_row(row) if row else None
def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]:
row = g.db.fetchone(
"""

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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))

View File

@@ -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()

View File

@@ -6,3 +6,9 @@ lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", te
from .views_api import * # noqa
from .views import * # noqa
from .lnurl import * # noqa
from .tasks import on_invoice_paid
from lnbits.core.tasks import register_invoice_listener
register_invoice_listener("lnurlp", on_invoice_paid)

View File

@@ -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),
)

View File

@@ -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/<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
url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True)
resp = LnurlPayResponse(
callback=url,
min_sendable=link.amount * 1000,
max_sendable=link.amount * 1000,
metadata=link.lnurlpay_metadata,
)
return jsonify(resp.dict()), HTTPStatus.OK
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"])
async def api_lnurl_callback(link_id):
link = increment_pay_link(link_id, served_pr=1)
if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
_, payment_request = create_invoice(
wallet_id=link.wallet,
amount=link.amount,
memo=link.description,
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
extra={"tag": "lnurlp"},
)
save_link_invoice(link_id, payment_request)
resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[])
return jsonify(resp.dict()), HTTPStatus.OK

View File

@@ -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
);
"""
)

View File

@@ -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

View File

@@ -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)

View File

@@ -131,6 +131,13 @@
type="number"
label="Amount (sat) *"
></q-input>
<q-input
filled
dense
v-model="formDialog.data.webhook_url"
type="text"
label="Webhook URL (optional)"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
@@ -149,7 +156,8 @@
(
formDialog.data.amount == null ||
formDialog.data.amount < 1
)"
)
"
type="submit"
>Create pay link</q-btn
>
@@ -174,6 +182,7 @@
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br />
<strong>Webhook:</strong> {{ qrCodeDialog.data.webhook_url }}<br />
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
@@ -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) {

View File

@@ -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/<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
url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True)
resp = LnurlPayResponse(
callback=url,
min_sendable=link.amount * 1000,
max_sendable=link.amount * 1000,
metadata=link.lnurlpay_metadata,
)
return jsonify(resp.dict()), HTTPStatus.OK
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"])
async def api_lnurl_callback(link_id):
link = increment_pay_link(link_id, served_pr=1)
if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
_, payment_request = create_invoice(
wallet_id=link.wallet,
amount=link.amount,
memo=link.description,
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
extra={"tag": "lnurlp"},
)
resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[])
return jsonify(resp.dict()), HTTPStatus.OK

View File

@@ -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 ["<local>"])[0]

View File

@@ -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

View File

@@ -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

View File

@@ -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 ""

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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 ""

View File

@@ -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

View File

@@ -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"]

View File

@@ -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 ""

2
pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
trio_mode = true

View File

@@ -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

View File

@@ -4,7 +4,6 @@ from lnbits.app import create_app
@pytest.fixture
@pytest.mark.asyncio
async def client():
app = create_app()
app.config["TESTING"] = True

View File

@@ -1,7 +1,6 @@
import pytest
@pytest.mark.asyncio
async def test_homepage(client):
r = await client.get("/")
assert b"Add a new wallet" in await r.get_data()