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
commit e1744caec5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 913 additions and 298 deletions

View file

@ -35,24 +35,21 @@ LNBITS_ADMIN_MACAROON=LNBITS_ADMIN_MACAROON
# LndWallet # LndWallet
LND_GRPC_ENDPOINT=127.0.0.1 LND_GRPC_ENDPOINT=127.0.0.1
LND_GRPC_PORT=11009 LND_GRPC_PORT=11009
LND_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert" LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_ADMIN_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon" LND_GRPC_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_GRPC_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"
# LndRestWallet # 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_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_REST_ADMIN_MACAROON="HEXSTRING" LND_REST_ADMIN_MACAROON="HEXSTRING"
LND_REST_INVOICE_MACAROON="HEXSTRING" LND_REST_INVOICE_MACAROON="HEXSTRING"
LND_REST_READ_MACAROON="HEXSTRING"
# LNPayWallet # LNPayWallet
LNPAY_API_ENDPOINT=https://lnpay.co/v1/ LNPAY_API_ENDPOINT=https://lnpay.co/v1/
LNPAY_API_KEY=LNPAY_API_KEY LNPAY_API_KEY=LNPAY_API_KEY
LNPAY_ADMIN_KEY=LNPAY_ADMIN_KEY LNPAY_ADMIN_KEY=LNPAY_ADMIN_KEY
LNPAY_INVOICE_KEY=LNPAY_INVOICE_KEY LNPAY_INVOICE_KEY=LNPAY_INVOICE_KEY
LNPAY_READ_KEY=LNPAY_READ_KEY
# LntxbotWallet # LntxbotWallet
LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/ LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/

View file

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

View file

@ -21,10 +21,13 @@ quart-compress = "*"
secure = "*" secure = "*"
typing-extensions = "*" typing-extensions = "*"
httpx = "*" httpx = "*"
quart-trio = "*"
trio = "*"
hypercorn = {extras = ["trio"], version = "*"}
[dev-packages] [dev-packages]
black = "==20.8b1" black = "==20.8b1"
pytest = "*" pytest = "*"
pytest-cov = "*" pytest-cov = "*"
pytest-asyncio = "*"
mypy = "==0.761" mypy = "==0.761"
pytest-trio = "*"

154
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "6f7d14aa2e3bc6a1319c7f0e2873151cefa741792fccc249567932a3a94263e3" "sha256": "76a3823f58d720ea680fdcd246f2a8b5fa16ce0a87a650e5e9fff5559dca7309"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -23,11 +23,28 @@
], ],
"version": "==0.5.0" "version": "==0.5.0"
}, },
"async-generator": {
"hashes": [
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
],
"markers": "python_version >= '3.5'",
"version": "==1.10"
},
"attrs": {
"hashes": [
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
"sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.2.0"
},
"bech32": { "bech32": {
"hashes": [ "hashes": [
"sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899", "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899",
"sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981" "sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981"
], ],
"markers": "python_version >= '3.5'",
"version": "==1.2.0" "version": "==1.2.0"
}, },
"bitstring": { "bitstring": {
@ -101,6 +118,7 @@
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" "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" "version": "==7.1.2"
}, },
"ecdsa": { "ecdsa": {
@ -131,6 +149,7 @@
"sha256:ac9e293a1990b339d5d71b19c5fe630e3dd4d768c620d1730d355485323f1b25", "sha256:ac9e293a1990b339d5d71b19c5fe630e3dd4d768c620d1730d355485323f1b25",
"sha256:bb7ac7099dd67a857ed52c815a6192b6b1f5ba6b516237fc24a085341340593d" "sha256:bb7ac7099dd67a857ed52c815a6192b6b1f5ba6b516237fc24a085341340593d"
], ],
"markers": "python_full_version >= '3.6.1'",
"version": "==4.0.0" "version": "==4.0.0"
}, },
"hpack": { "hpack": {
@ -138,6 +157,7 @@
"sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c", "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c",
"sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095" "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"
], ],
"markers": "python_full_version >= '3.6.1'",
"version": "==4.0.0" "version": "==4.0.0"
}, },
"httpcore": { "httpcore": {
@ -145,21 +165,26 @@
"sha256:72cfaa461dbdc262943ff4c9abf5b195391a03cdcc152e636adb4239b15e77e1", "sha256:72cfaa461dbdc262943ff4c9abf5b195391a03cdcc152e636adb4239b15e77e1",
"sha256:a35dddd1f4cc34ff37788337ef507c0ad0276241ece6daf663ac9e77c0b87232" "sha256:a35dddd1f4cc34ff37788337ef507c0ad0276241ece6daf663ac9e77c0b87232"
], ],
"markers": "python_version >= '3.6'",
"version": "==0.11.1" "version": "==0.11.1"
}, },
"httpx": { "httpx": {
"hashes": [ "hashes": [
"sha256:4c81dbf98a29cb4f51f415140df56542f9d4860798d713e336642e953cddd1db", "sha256:02326f2d3c61133db31e4b88dd3432479b434e52a68d813eab6db930f13611ea",
"sha256:7b3c07bfdcdadd92020dd4c07b15932abdcf1c898422a4e98de3d19b2223310b" "sha256:254b371e3880a8e2387bf9ead6949bac797bd557fda26eba19a6153a0c06bd2b"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.15.4" "version": "==0.15.5"
}, },
"hypercorn": { "hypercorn": {
"extras": [
"trio"
],
"hashes": [ "hashes": [
"sha256:6540faeba9dd44f7e74c7cc1beae3a438a7efb5f77323d1199457da46d32c2c2", "sha256:6540faeba9dd44f7e74c7cc1beae3a438a7efb5f77323d1199457da46d32c2c2",
"sha256:b5c479023757e279f954b46a4ec9dd85e58a2bcbf4d959d5601cbced593e711d" "sha256:b5c479023757e279f954b46a4ec9dd85e58a2bcbf4d959d5601cbced593e711d"
], ],
"index": "pypi",
"version": "==0.11.0" "version": "==0.11.0"
}, },
"hyperframe": { "hyperframe": {
@ -167,6 +192,7 @@
"sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1", "sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1",
"sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34" "sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34"
], ],
"markers": "python_full_version >= '3.6.1'",
"version": "==6.0.0" "version": "==6.0.0"
}, },
"idna": { "idna": {
@ -181,6 +207,7 @@
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.0" "version": "==1.1.0"
}, },
"jinja2": { "jinja2": {
@ -188,6 +215,7 @@
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" "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" "version": "==2.11.2"
}, },
"lnurl": { "lnurl": {
@ -233,6 +261,7 @@
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.1" "version": "==1.1.1"
}, },
"marshmallow": { "marshmallow": {
@ -240,8 +269,17 @@
"sha256:2272273505f1644580fbc66c6b220cc78f893eb31f1ecde2af98ad28011e9811", "sha256:2272273505f1644580fbc66c6b220cc78f893eb31f1ecde2af98ad28011e9811",
"sha256:47911dd7c641a27160f0df5fd0fe94667160ffe97f70a42c3cc18388d86098cc" "sha256:47911dd7c641a27160f0df5fd0fe94667160ffe97f70a42c3cc18388d86098cc"
], ],
"markers": "python_version >= '3.5'",
"version": "==3.8.0" "version": "==3.8.0"
}, },
"outcome": {
"hashes": [
"sha256:ee46c5ce42780cde85d55a61819d0e6b8cb490f1dbd749ba75ff2629771dcd2d",
"sha256:fc7822068ba7dd0fc2532743611e8a73246708d3564e29a39f93d6ab3701b66f"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.0.1"
},
"priority": { "priority": {
"hashes": [ "hashes": [
"sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe", "sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe",
@ -269,6 +307,7 @@
"sha256:f769141ab0abfadf3305d4fcf36660e5cf568a666dd3efab7c3d4782f70946b1", "sha256:f769141ab0abfadf3305d4fcf36660e5cf568a666dd3efab7c3d4782f70946b1",
"sha256:f8af9b840a9074e08c0e6dc93101de84ba95df89b267bf7151d74c553d66833b" "sha256:f8af9b840a9074e08c0e6dc93101de84ba95df89b267bf7151d74c553d66833b"
], ],
"markers": "python_version >= '3.6'",
"version": "==1.6.1" "version": "==1.6.1"
}, },
"pyscss": { "pyscss": {
@ -309,6 +348,14 @@
"index": "pypi", "index": "pypi",
"version": "==0.3.0" "version": "==0.3.0"
}, },
"quart-trio": {
"hashes": [
"sha256:00f3b20f8d82ce7e81ead61db4efba38ed7653c7e28199defded46b663ab2595",
"sha256:dafc8f0440d4b70fa60d24122a161d2373894d2bfa9f713d9f1df1fd508f0834"
],
"index": "pypi",
"version": "==0.5.1"
},
"requests": { "requests": {
"hashes": [ "hashes": [
"sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
@ -318,6 +365,9 @@
"version": "==2.24.0" "version": "==2.24.0"
}, },
"rfc3986": { "rfc3986": {
"extras": [
"idna2008"
],
"hashes": [ "hashes": [
"sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d", "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d",
"sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50" "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"
@ -345,6 +395,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0" "version": "==1.15.0"
}, },
"sniffio": { "sniffio": {
@ -352,8 +403,16 @@
"sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5", "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5",
"sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21" "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"
], ],
"markers": "python_version >= '3.5'",
"version": "==1.1.0" "version": "==1.1.0"
}, },
"sortedcontainers": {
"hashes": [
"sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
"sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
],
"version": "==2.2.2"
},
"toml": { "toml": {
"hashes": [ "hashes": [
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
@ -361,6 +420,14 @@
], ],
"version": "==0.10.1" "version": "==0.10.1"
}, },
"trio": {
"hashes": [
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
],
"index": "pypi",
"version": "==0.17.0"
},
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
@ -375,6 +442,7 @@
"sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" "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" "version": "==1.25.10"
}, },
"werkzeug": { "werkzeug": {
@ -382,6 +450,7 @@
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",
"sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" "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" "version": "==1.0.1"
}, },
"wsproto": { "wsproto": {
@ -389,6 +458,7 @@
"sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d", "sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d",
"sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d" "sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d"
], ],
"markers": "python_full_version >= '3.6.1'",
"version": "==0.15.0" "version": "==0.15.0"
} }
}, },
@ -400,11 +470,20 @@
], ],
"version": "==1.4.4" "version": "==1.4.4"
}, },
"async-generator": {
"hashes": [
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
],
"markers": "python_version >= '3.5'",
"version": "==1.10"
},
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
"sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.2.0" "version": "==20.2.0"
}, },
"black": { "black": {
@ -419,6 +498,7 @@
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" "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" "version": "==7.1.2"
}, },
"coverage": { "coverage": {
@ -458,8 +538,16 @@
"sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
"sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" "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" "version": "==5.3"
}, },
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
"version": "==2.10"
},
"iniconfig": { "iniconfig": {
"hashes": [ "hashes": [
"sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
@ -494,11 +582,20 @@
], ],
"version": "==0.4.3" "version": "==0.4.3"
}, },
"outcome": {
"hashes": [
"sha256:ee46c5ce42780cde85d55a61819d0e6b8cb490f1dbd749ba75ff2629771dcd2d",
"sha256:fc7822068ba7dd0fc2532743611e8a73246708d3564e29a39f93d6ab3701b66f"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.0.1"
},
"packaging": { "packaging": {
"hashes": [ "hashes": [
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
"sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.4" "version": "==20.4"
}, },
"pathspec": { "pathspec": {
@ -513,6 +610,7 @@
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.13.1" "version": "==0.13.1"
}, },
"py": { "py": {
@ -520,6 +618,7 @@
"sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
"sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.9.0" "version": "==1.9.0"
}, },
"pyparsing": { "pyparsing": {
@ -527,23 +626,16 @@
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
], ],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.4.7" "version": "==2.4.7"
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [
"sha256:1cd09785c0a50f9af72220dd12aa78cfa49cbffc356c61eab009ca189e018a33", "sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9",
"sha256:d010e24666435b39a4cf48740b039885642b6c273a3f77be3e7e03554d2806b7" "sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92"
], ],
"index": "pypi", "index": "pypi",
"version": "==6.1.0" "version": "==6.1.1"
},
"pytest-asyncio": {
"hashes": [
"sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d",
"sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"
],
"index": "pypi",
"version": "==0.14.0"
}, },
"pytest-cov": { "pytest-cov": {
"hashes": [ "hashes": [
@ -553,6 +645,14 @@
"index": "pypi", "index": "pypi",
"version": "==2.10.1" "version": "==2.10.1"
}, },
"pytest-trio": {
"hashes": [
"sha256:3f48cc1df66d279d705af38ad38d1639c2e2380ddffcdc3a45bb81758de61f03",
"sha256:9bf0a490fd177a33617e8709242293fae47934de2b51f8209eb2c0545b6ca8fe"
],
"index": "pypi",
"version": "==0.6.0"
},
"regex": { "regex": {
"hashes": [ "hashes": [
"sha256:088afc8c63e7bd187a3c70a94b9e50ab3f17e1d3f52a32750b5b77dbe99ef5ef", "sha256:088afc8c63e7bd187a3c70a94b9e50ab3f17e1d3f52a32750b5b77dbe99ef5ef",
@ -584,8 +684,24 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0" "version": "==1.15.0"
}, },
"sniffio": {
"hashes": [
"sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5",
"sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"
],
"markers": "python_version >= '3.5'",
"version": "==1.1.0"
},
"sortedcontainers": {
"hashes": [
"sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
"sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
],
"version": "==2.2.2"
},
"toml": { "toml": {
"hashes": [ "hashes": [
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
@ -593,6 +709,14 @@
], ],
"version": "==0.10.1" "version": "==0.10.1"
}, },
"trio": {
"hashes": [
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
],
"index": "pypi",
"version": "==0.17.0"
},
"typed-ast": { "typed-ast": {
"hashes": [ "hashes": [
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",

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) ![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. 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. 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 cp .env.example .env
./venv/bin/quart assets ./venv/bin/quart assets
./venv/bin/quart migrate ./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/. 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: E.g. when you want to use LND you have to run:
```sh ```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) ### LND (gRPC)
Using this wallet requires the installation of the `lnd-grpc` Python package. Using this wallet requires the installation of the `lndgrpc` and `purerpc` Python packages.
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet** - `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
- `LND_GRPC_ENDPOINT`: ip_address - `LND_GRPC_ENDPOINT`: ip_address

View file

@ -1,24 +1,26 @@
import trio # type: ignore
import importlib 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_cors import cors # type: ignore
from quart_compress import Compress # type: ignore from quart_compress import Compress # type: ignore
from secure import SecureHeaders # type: ignore from secure import SecureHeaders # type: ignore
from .commands import db_migrate, handle_assets from .commands import db_migrate, handle_assets
from .core import core_app from .core import core_app
from .db import open_db from .db import open_db, open_ext_db
from .helpers import get_valid_extensions, get_js_vendored, get_css_vendored, url_for_vendored from .helpers import get_valid_extensions, get_js_vendored, get_css_vendored, url_for_vendored
from .proxy_fix import ASGIProxyFix from .proxy_fix import ASGIProxyFix
secure_headers = SecureHeaders(hsts=False) secure_headers = SecureHeaders(hsts=False)
def create_app(config_object="lnbits.settings") -> Quart: def create_app(config_object="lnbits.settings") -> QuartTrio:
"""Create application factory. """Create application factory.
:param config_object: The configuration object to use. :param config_object: The configuration object to use.
""" """
app = Quart(__name__, static_folder="static") app = QuartTrio(__name__, static_folder="static")
app.config.from_object(config_object) app.config.from_object(config_object)
app.asgi_http_class = ASGIProxyFix app.asgi_http_class = ASGIProxyFix
@ -30,29 +32,40 @@ def create_app(config_object="lnbits.settings") -> Quart:
register_filters(app) register_filters(app)
register_commands(app) register_commands(app)
register_request_hooks(app) register_request_hooks(app)
register_async_tasks(app)
return app return app
def register_blueprints(app: Quart) -> None: def register_blueprints(app: QuartTrio) -> None:
"""Register Flask blueprints / LNbits extensions.""" """Register Flask blueprints / LNbits extensions."""
app.register_blueprint(core_app) app.register_blueprint(core_app)
for ext in get_valid_extensions(): for ext in get_valid_extensions():
try: try:
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}") ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
app.register_blueprint(getattr(ext_module, f"{ext.code}_ext"), url_prefix=f"/{ext.code}") bp = getattr(ext_module, f"{ext.code}_ext")
@bp.before_request
async def before_request():
g.ext_db = open_ext_db(ext.code)
@bp.teardown_request
async def after_request(exc):
g.ext_db.__exit__(type(exc), exc, None)
app.register_blueprint(bp, url_prefix=f"/{ext.code}")
except Exception: except Exception:
raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.") raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.")
def register_commands(app: Quart): def register_commands(app: QuartTrio):
"""Register Click commands.""" """Register Click commands."""
app.cli.add_command(db_migrate) app.cli.add_command(db_migrate)
app.cli.add_command(handle_assets) app.cli.add_command(handle_assets)
def register_assets(app: Quart): def register_assets(app: QuartTrio):
"""Serve each vendored asset separately or a bundle.""" """Serve each vendored asset separately or a bundle."""
@app.before_request @app.before_request
@ -65,13 +78,13 @@ def register_assets(app: Quart):
g.VENDORED_CSS = ["/static/bundle.css"] g.VENDORED_CSS = ["/static/bundle.css"]
def register_filters(app: Quart): def register_filters(app: QuartTrio):
"""Jinja filters.""" """Jinja filters."""
app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"] app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"]
app.jinja_env.globals["EXTENSIONS"] = get_valid_extensions() app.jinja_env.globals["EXTENSIONS"] = get_valid_extensions()
def register_request_hooks(app: Quart): def register_request_hooks(app: QuartTrio):
"""Open the core db for each request so everything happens in a big transaction""" """Open the core db for each request so everything happens in a big transaction"""
@app.before_request @app.before_request
@ -86,3 +99,20 @@ def register_request_hooks(app: Quart):
@app.teardown_request @app.teardown_request
async def after_request(exc): async def after_request(exc):
g.db.__exit__(type(exc), exc, None) g.db.__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]: def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]:
row = g.db.fetchone( row = g.db.fetchone(
""" """

View file

@ -2,6 +2,8 @@ import json
from typing import List, NamedTuple, Optional, Dict from typing import List, NamedTuple, Optional, Dict
from sqlite3 import Row from sqlite3 import Row
from lnbits.settings import WALLET
class User(NamedTuple): class User(NamedTuple):
id: str id: str
@ -113,6 +115,17 @@ class Payment(NamedTuple):
update_payment_status(self.checking_id, pending) update_payment_status(self.checking_id, pending)
def check_pending(self) -> None:
if self.is_uncheckable:
return
if self.is_out:
pending = WALLET.get_payment_status(self.checking_id)
else:
pending = WALLET.get_invoice_status(self.checking_id)
self.set_pending(pending.pending)
def delete(self) -> None: def delete(self) -> None:
from .crud import delete_payment from .crud import delete_payment

View file

@ -1,11 +1,17 @@
import asyncio import trio # type: ignore
from typing import Optional, Awaitable from http import HTTPStatus
from quart import Quart, Request, g from typing import Optional, Tuple, List, Callable, Awaitable
from quart import Request, g
from quart_trio import QuartTrio
from werkzeug.datastructures import Headers 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): def grab_app_for_later(state):
@ -13,8 +19,11 @@ def grab_app_for_later(state):
main_app = state.app main_app = state.app
def run_on_pseudo_request(awaitable: Awaitable): async def send_push_promise(a, b) -> None:
async def run(awaitable): pass
async def run_on_pseudo_request(func: Callable, *args):
fk = Request( fk = Request(
"GET", "GET",
"http", "http",
@ -23,11 +32,47 @@ def run_on_pseudo_request(awaitable: Awaitable):
Headers([("host", "lnbits.background")]), Headers([("host", "lnbits.background")]),
"", "",
"1.1", "1.1",
send_push_promise=lambda x, h: None, send_push_promise=send_push_promise,
) )
async with main_app.request_context(fk): assert main_app
g.db = open_db()
await awaitable
loop = asyncio.get_event_loop() async def run():
loop.create_task(run(awaitable)) 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.services import create_invoice, pay_invoice
from lnbits.core.crud import delete_expired_invoices from lnbits.core.crud import delete_expired_invoices
from lnbits.decorators import api_check_wallet_key, api_validate_post_request from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.settings import WALLET
@core_app.route("/api/v1/wallet", methods=["GET"]) @core_app.route("/api/v1/wallet", methods=["GET"])
@ -32,10 +31,7 @@ async def api_payments():
delete_expired_invoices() delete_expired_invoices()
for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True): for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True):
if payment.is_out: payment.check_pending()
payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
else:
payment.set_pending(WALLET.get_invoice_status(payment.checking_id).pending)
return jsonify(g.wallet.get_payments(pending=True)), HTTPStatus.OK 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 return jsonify({"paid": True}), HTTPStatus.OK
try: try:
if payment.is_uncheckable: payment.check_pending()
pass
elif payment.is_out:
is_paid = not WALLET.get_payment_status(payment.checking_id).pending
elif payment.is_in:
is_paid = not WALLET.get_invoice_status(payment.checking_id).pending
except Exception: except Exception:
return jsonify({"paid": False}), HTTPStatus.OK return jsonify({"paid": False}), HTTPStatus.OK
if is_paid: return jsonify({"paid": not payment.pending}), HTTPStatus.OK
payment.set_pending(False)
return jsonify({"paid": True}), HTTPStatus.OK
return jsonify({"paid": False}), HTTPStatus.OK

View file

@ -123,6 +123,6 @@ async def lnurlwallet():
user = get_user(account.id) user = get_user(account.id)
wallet = create_wallet(user_id=user.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)) 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 = sqlite3.connect(db_path)
self.connection.row_factory = sqlite3.Row self.connection.row_factory = sqlite3.Row
self.cursor = self.connection.cursor() self.cursor = self.connection.cursor()
self.closed = False
def __enter__(self): def __enter__(self):
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
if self.closed:
return
if exc_val: if exc_val:
self.connection.rollback() self.connection.rollback()
self.cursor.close() self.cursor.close()
self.cursor.close() self.connection.close()
else: else:
self.connection.commit() self.connection.commit()
self.cursor.close() self.cursor.close()
self.connection.close() self.connection.close()
self.closed = True
def commit(self): def commit(self):
self.connection.commit() self.connection.commit()

View file

@ -6,3 +6,9 @@ lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", te
from .views_api import * # noqa from .views_api import * # noqa
from .views import * # noqa from .views import * # noqa
from .lnurl import * # noqa
from .tasks import 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 typing import List, Optional, Union
from lnbits import bolt11
from lnbits.db import open_ext_db from lnbits.db import open_ext_db
from .models import PayLink from .models import PayLink
def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink: def create_pay_link(*, wallet_id: str, description: str, amount: int, webhook_url: str) -> Optional[PayLink]:
with open_ext_db("lnurlp") as db: with open_ext_db("lnurlp") as db:
db.execute( db.execute(
""" """
@ -14,26 +15,36 @@ def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink
description, description,
amount, amount,
served_meta, 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 link_id = db.cursor.lastrowid
return get_pay_link(link_id) return get_pay_link(link_id)
def get_pay_link(link_id: str) -> Optional[PayLink]: def get_pay_link(link_id: int) -> Optional[PayLink]:
with open_ext_db("lnurlp") as db: with open_ext_db("lnurlp") as db:
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None return PayLink.from_row(row) if row else None
def get_pay_link_by_hash(unique_hash: str) -> Optional[PayLink]: 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: 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 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] return [PayLink.from_row(row) for row in rows]
def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("lnurlp") as db: with open_ext_db("lnurlp") as db:
@ -59,7 +70,7 @@ def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
return PayLink.from_row(row) if row else None return PayLink.from_row(row) if row else None
def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
with open_ext_db("lnurlp") as db: with open_ext_db("lnurlp") as db:
@ -69,6 +80,30 @@ def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
return PayLink.from_row(row) if row else None return PayLink.from_row(row) if row else None
def delete_pay_link(link_id: str) -> None: def delete_pay_link(link_id: int) -> None:
with open_ext_db("lnurlp") as db: with open_ext_db("lnurlp") as db:
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,)) db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
def 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): class PayLink(NamedTuple):
id: str id: int
wallet: str wallet: str
description: str description: str
amount: int amount: int
served_meta: int served_meta: int
served_pr: int served_pr: int
webhook_url: str
success_text: str
success_url: str
@classmethod @classmethod
def from_row(cls, row: Row) -> "PayLink": def from_row(cls, row: Row) -> "PayLink":
@ -27,3 +30,9 @@ class PayLink(NamedTuple):
@property @property
def lnurlpay_metadata(self) -> LnurlPayMetadata: def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.description]])) return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
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" type="number"
label="Amount (sat) *" label="Amount (sat) *"
></q-input> ></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"> <div class="row q-mt-lg">
<q-btn <q-btn
v-if="formDialog.data.id" v-if="formDialog.data.id"
@ -149,7 +156,8 @@
( (
formDialog.data.amount == null || formDialog.data.amount == null ||
formDialog.data.amount < 1 formDialog.data.amount < 1
)" )
"
type="submit" type="submit"
>Create pay link</q-btn >Create pay link</q-btn
> >
@ -174,6 +182,7 @@
<p style="word-break: break-all"> <p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br /> <strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br /> <strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br />
<strong>Webhook:</strong> {{ qrCodeDialog.data.webhook_url }}<br />
</p> </p>
{% endraw %} {% endraw %}
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
@ -248,6 +257,12 @@
align: 'right', align: 'right',
label: 'Amount (sat)', label: 'Amount (sat)',
field: 'amount' field: 'amount'
},
{
name: 'webhook_url',
align: 'left',
label: 'Webhook URL',
field: 'webhook_url'
} }
], ],
pagination: { pagination: {
@ -331,7 +346,7 @@
'PUT', 'PUT',
'/lnurlp/api/v1/links/' + data.id, '/lnurlp/api/v1/links/' + data.id,
wallet.adminkey, wallet.adminkey,
_.pick(data, 'description', 'amount') _.pick(data, 'description', 'amount', 'webhook_url')
) )
.then(function (response) { .then(function (response) {
self.payLinks = _.reject(self.payLinks, function (obj) { self.payLinks = _.reject(self.payLinks, function (obj) {

View file

@ -1,11 +1,8 @@
import hashlib from quart import g, jsonify, request
from quart import g, jsonify, request, url_for
from http import HTTPStatus from http import HTTPStatus
from lnurl import LnurlPayResponse, LnurlPayActionResponse
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.decorators import api_check_wallet_key, api_validate_post_request from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.extensions.lnurlp import lnurlp_ext from lnbits.extensions.lnurlp import lnurlp_ext
@ -14,7 +11,6 @@ from .crud import (
get_pay_link, get_pay_link,
get_pay_links, get_pay_links,
update_pay_link, update_pay_link,
increment_pay_link,
delete_pay_link, delete_pay_link,
) )
@ -60,6 +56,7 @@ async def api_link_retrieve(link_id):
schema={ schema={
"description": {"type": "string", "empty": False, "required": True}, "description": {"type": "string", "empty": False, "required": True},
"amount": {"type": "integer", "min": 1, "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): 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) delete_pay_link(link_id)
return "", HTTPStatus.NO_CONTENT 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 werkzeug.datastructures import Headers
from quart import Request 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: def _create_request_from_scope(self, send: Callable) -> Request:
headers = Headers() headers = Headers()
headers["Remote-Addr"] = (self.scope.get("client") or ["<local>"])[0] headers["Remote-Addr"] = (self.scope.get("client") or ["<local>"])[0]

View file

@ -1,5 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import NamedTuple, Optional from typing import NamedTuple, Optional, AsyncGenerator
class InvoiceResponse(NamedTuple): class InvoiceResponse(NamedTuple):
@ -43,6 +43,15 @@ class Wallet(ABC):
def get_payment_status(self, checking_id: str) -> PaymentStatus: def get_payment_status(self, checking_id: str) -> PaymentStatus:
pass pass
@abstractmethod
def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
"""
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): class Unsupported(Exception):
pass pass

View file

@ -1,12 +1,14 @@
try: try:
from lightning import LightningRpc # type: ignore from lightning import LightningRpc, RpcError # type: ignore
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
LightningRpc = None LightningRpc = None
import trio # type: ignore
import random import random
import json
from os import getenv from os import getenv
from typing import Optional from typing import Optional, AsyncGenerator
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
@ -15,34 +17,63 @@ class CLightningWallet(Wallet):
if LightningRpc is None: # pragma: nocover if LightningRpc is None: # pragma: nocover
raise ImportError("The `pylightning` library must be installed to use `CLightningWallet`.") raise ImportError("The `pylightning` library must be installed to use `CLightningWallet`.")
self.l1 = LightningRpc(getenv("CLIGHTNING_RPC")) self.rpc = getenv("CLIGHTNING_RPC")
self.ln = LightningRpc(self.rpc)
# check description_hash support (could be provided by a plugin)
self.supports_description_hash = False
try:
answer = self.ln.help("invoicewithdescriptionhash")
if answer["help"][0]["command"].startswith(
"invoicewithdescriptionhash msatoshi label description_hash",
):
self.supports_description_hash = True
except:
pass
# check last payindex so we can listen from that point on
self.last_pay_index = 0
invoices = self.ln.listinvoices()
for inv in invoices["invoices"][::-1]:
if "pay_index" in inv:
self.last_pay_index = inv["pay_index"]
break
def create_invoice( def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
) -> InvoiceResponse: ) -> InvoiceResponse:
label = "lbl{}".format(random.random())
msat = amount * 1000
try:
if description_hash: if description_hash:
if not self.supports_description_hash:
raise Unsupported("description_hash") raise Unsupported("description_hash")
label = "lbl{}".format(random.random()) params = [msat, label, description_hash.hex()]
r = self.l1.invoice(amount * 1000, label, memo, exposeprivatechannels=True) r = self.ln.call("invoicewithdescriptionhash", params)
ok, checking_id, payment_request, error_message = True, r["payment_hash"], r["bolt11"], None return InvoiceResponse(True, label, r["bolt11"], "")
return InvoiceResponse(ok, checking_id, payment_request, error_message) else:
r = self.ln.invoice(msat, label, memo, exposeprivatechannels=True)
return InvoiceResponse(True, label, r["bolt11"], "")
except RpcError as exc:
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
return InvoiceResponse(False, label, None, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse: def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = self.l1.pay(bolt11) r = self.ln.pay(bolt11)
ok, checking_id, fee_msat, error_message = True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None return PaymentResponse(True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None)
return PaymentResponse(ok, checking_id, fee_msat, error_message)
def get_invoice_status(self, checking_id: str) -> PaymentStatus: def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = self.l1.listinvoices(checking_id) r = self.ln.listinvoices(checking_id)
if not r["invoices"]: if not r["invoices"]:
return PaymentStatus(False) return PaymentStatus(False)
if r["invoices"][0]["label"] == checking_id: if r["invoices"][0]["label"] == checking_id:
return PaymentStatus(r["pays"][0]["status"] == "paid") return PaymentStatus(r["invoices"][0]["status"] == "paid")
raise KeyError("supplied an invalid checking_id") raise KeyError("supplied an invalid checking_id")
def get_payment_status(self, checking_id: str) -> PaymentStatus: def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = self.l1.listpays(payment_hash=checking_id) r = self.ln.listpays(payment_hash=checking_id)
if not r["pays"]: if not r["pays"]:
return PaymentStatus(False) return PaymentStatus(False)
if r["pays"][0]["payment_hash"] == checking_id: if r["pays"][0]["payment_hash"] == checking_id:
@ -53,3 +84,27 @@ class CLightningWallet(Wallet):
return PaymentStatus(False) return PaymentStatus(False)
return PaymentStatus(None) return PaymentStatus(None)
raise KeyError("supplied an invalid checking_id") raise KeyError("supplied an invalid checking_id")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
stream = await trio.open_unix_socket(self.rpc)
i = 0
while True:
call = json.dumps(
{
"method": "waitanyinvoice",
"id": 0,
"params": [self.last_pay_index],
}
)
await stream.send_all(call.encode("utf-8"))
data = await stream.receive_some()
paid = json.loads(data.decode("ascii"))
paid = self.ln.waitanyinvoice(self.last_pay_index)
self.last_pay_index = paid["pay_index"]
yield paid["label"]
i += 1

View file

@ -1,5 +1,6 @@
import trio # type: ignore
from os import getenv from os import getenv
from typing import Optional, Dict from typing import Optional, Dict, AsyncGenerator
from requests import get, post from requests import get, post
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
@ -64,3 +65,8 @@ class LNbitsWallet(Wallet):
return PaymentStatus(None) return PaymentStatus(None)
return PaymentStatus(r.json()["paid"]) return PaymentStatus(r.json()["paid"])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
print("lnbits does not support paid invoices stream yet")
await trio.sleep(5)
yield ""

View file

@ -1,92 +1,181 @@
try: try:
import lnd_grpc # type: ignore import lndgrpc # type: ignore
from lndgrpc.common import ln # type: ignore
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
lnd_grpc = None lndgrpc = None
try:
import purerpc # type: ignore
except ImportError: # pragma: nocover
purerpc = None
import binascii
import base64 import base64
import hashlib
from os import getenv from os import getenv
from typing import Optional, Dict from typing import Optional, Dict, AsyncGenerator
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet from .base import 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): class LndWallet(Wallet):
def __init__(self): def __init__(self):
if lnd_grpc is None: # pragma: nocover if lndgrpc is None: # pragma: nocover
raise ImportError("The `lnd-grpc` library must be installed to use `LndWallet`.") raise ImportError("The `lndgrpc` library must be installed to use `LndWallet`.")
if purerpc is None: # pragma: nocover
raise ImportError("The `purerpc` library must be installed to use `LndWallet`.")
endpoint = getenv("LND_GRPC_ENDPOINT") endpoint = getenv("LND_GRPC_ENDPOINT")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
self.port = getenv("LND_GRPC_PORT") self.port = int(getenv("LND_GRPC_PORT"))
self.auth_admin = getenv("LND_ADMIN_MACAROON") self.cert_path = getenv("LND_GRPC_CERT") or getenv("LND_CERT")
self.auth_invoice = getenv("LND_INVOICE_MACAROON") self.auth_admin = getenv("LND_GRPC_ADMIN_MACAROON") or getenv("LND_ADMIN_MACAROON")
self.auth_read = getenv("LND_READ_MACAROON") self.auth_invoices = getenv("LND_GRPC_INVOICE_MACAROON") or getenv("LND_INVOICE_MACAROON")
self.auth_cert = getenv("LND_CERT") 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( def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
) -> InvoiceResponse: ) -> InvoiceResponse:
lnd_rpc = lnd_grpc.Client(
lnd_dir=None,
macaroon_path=self.auth_invoice,
tls_cert_path=self.auth_cert,
network="mainnet",
grpc_host=self.endpoint,
grpc_port=self.port,
)
params: Dict = {"value": amount, "expiry": 600, "private": True} params: Dict = {"value": amount, "expiry": 600, "private": True}
if description_hash: if description_hash:
params["description_hash"] = description_hash # as bytes directly params["description_hash"] = description_hash # as bytes directly
else: else:
params["memo"] = memo or "" params["memo"] = memo or ""
lndResponse = lnd_rpc.add_invoice(**params)
decoded_hash = base64.b64encode(lndResponse.r_hash).decode("utf-8").replace("/", "_") try:
ok, checking_id, payment_request, error_message = True, decoded_hash, str(lndResponse.payment_request), None req = ln.Invoice(**params)
return InvoiceResponse(ok, checking_id, payment_request, error_message) resp = self.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: def pay_invoice(self, bolt11: str) -> PaymentResponse:
lnd_rpc = lnd_grpc.Client( resp = self.admin_rpc.send_payment(payment_request=bolt11)
lnd_dir=None,
macaroon_path=self.auth_admin,
tls_cert_path=self.auth_cert,
network="mainnet",
grpc_host=self.endpoint,
grpc_port=self.port,
)
payinvoice = lnd_rpc.pay_invoice( if resp.payment_error:
payment_request=bolt11, return PaymentResponse(False, "", 0, resp.payment_error)
)
ok, checking_id, fee_msat, error_message = True, None, 0, None r_hash = hashlib.sha256(resp.payment_preimage).digest()
checking_id = stringify_checking_id(r_hash)
if payinvoice.payment_error: return PaymentResponse(True, checking_id, 0, None)
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)
def get_invoice_status(self, checking_id: str) -> PaymentStatus: def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r_hash = parse_checking_id(checking_id)
if len(r_hash) != 32:
raise binascii.Error
except binascii.Error:
# this may happen if we switch between backend wallets
# that use different checking_id formats
return PaymentStatus(None)
check_id = base64.b64decode(checking_id.replace("_", "/")) resp = self.invoices_rpc.lookup_invoice(r_hash.hex())
print(check_id) if resp.settled:
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) return PaymentStatus(True)
return PaymentStatus(None) return PaymentStatus(None)
def get_payment_status(self, checking_id: str) -> PaymentStatus: def get_payment_status(self, checking_id: str) -> PaymentStatus:
return PaymentStatus(True) return PaymentStatus(True)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
async with purerpc.secure_channel(
self.endpoint,
self.port,
get_ssl_context(self.cert_path),
) as channel:
client = purerpc.Client("lnrpc.Lightning", channel)
subscribe_invoices = client.get_method_stub(
"SubscribeInvoices",
purerpc.RPCSignature(
purerpc.Cardinality.UNARY_STREAM,
ln.InvoiceSubscription,
ln.Invoice,
),
)
macaroon = load_macaroon(self.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 import httpx
from typing import Optional, Dict import json
import base64 import base64
from requests import get, post from os import getenv
from typing import Optional, Dict, AsyncGenerator
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
@ -11,11 +13,16 @@ class LndRestWallet(Wallet):
def __init__(self): def __init__(self):
endpoint = getenv("LND_REST_ENDPOINT") endpoint = getenv("LND_REST_ENDPOINT")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
print(self.endpoint) endpoint = "https://" + endpoint if not endpoint.startswith("http") else endpoint
self.auth_admin = {"Grpc-Metadata-macaroon": getenv("LND_REST_ADMIN_MACAROON")} self.endpoint = endpoint
self.auth_invoice = {"Grpc-Metadata-macaroon": getenv("LND_REST_INVOICE_MACAROON")}
self.auth_read = {"Grpc-Metadata-macaroon": getenv("LND_REST_READ_MACAROON")} 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") self.auth_cert = getenv("LND_REST_CERT")
def create_invoice( def create_invoice(
@ -30,84 +37,93 @@ class LndRestWallet(Wallet):
else: else:
data["memo"] = memo or "" data["memo"] = memo or ""
r = post( r = httpx.post(
url=f"{self.endpoint}/v1/invoices", url=f"{self.endpoint}/v1/invoices",
headers=self.auth_invoice, headers=self.auth_invoice,
verify=self.auth_cert, verify=self.auth_cert,
json=data, json=data,
) )
ok, checking_id, payment_request, error_message = r.ok, None, None, None if r.is_error:
error_message = r.text
try:
error_message = r.json()["error"]
except Exception:
pass
return InvoiceResponse(False, None, None, error_message)
if r.ok:
data = r.json() data = r.json()
payment_request = data["payment_request"] payment_request = data["payment_request"]
payment_hash = base64.b64decode(data["r_hash"]).hex()
checking_id = payment_hash
r = get( return InvoiceResponse(True, checking_id, payment_request, None)
url=f"{self.endpoint}/v1/payreq/{payment_request}",
headers=self.auth_read,
verify=self.auth_cert,
)
print(r)
if r.ok:
checking_id = r.json()["payment_hash"].replace("/", "_")
print(checking_id)
error_message = None
ok = True
return InvoiceResponse(ok, checking_id, payment_request, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse: def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post( r = httpx.post(
url=f"{self.endpoint}/v1/channels/transactions", url=f"{self.endpoint}/v1/channels/transactions",
headers=self.auth_admin, headers=self.auth_admin,
verify=self.auth_cert, verify=self.auth_cert,
json={"payment_request": bolt11}, 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: if r.is_error:
checking_id = r.json()["payment_hash"] error_message = r.text
else: try:
error_message = r.json()["error"] error_message = r.json()["error"]
except:
pass
return PaymentResponse(False, None, 0, 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: def get_invoice_status(self, checking_id: str) -> PaymentStatus:
checking_id = checking_id.replace("_", "/") checking_id = checking_id.replace("_", "/")
print(checking_id) r = httpx.get(
r = get(
url=f"{self.endpoint}/v1/invoice/{checking_id}", url=f"{self.endpoint}/v1/invoice/{checking_id}",
headers=self.auth_invoice, headers=self.auth_invoice,
verify=self.auth_cert, verify=self.auth_cert,
) )
print(r.json()["settled"])
if not r or r.json()["settled"] == False: if not r or r.json()["settled"] == False:
return PaymentStatus(None) return PaymentStatus(None)
return PaymentStatus(r.json()["settled"]) return PaymentStatus(r.json()["settled"])
def get_payment_status(self, checking_id: str) -> PaymentStatus: def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = get( r = httpx.get(
url=f"{self.endpoint}/v1/payments", url=f"{self.endpoint}/v1/payments",
headers=self.auth_admin, headers=self.auth_admin,
verify=self.auth_cert, verify=self.auth_cert,
params={"include_incomplete": "True", "max_payments": "20"}, params={"include_incomplete": "True", "max_payments": "20"},
) )
if not r.ok: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
payments = [p for p in r.json()["payments"] if p["payment_hash"] == checking_id] payments = [p for p in r.json()["payments"] if p["payment_hash"] == checking_id]
print(checking_id)
payment = payments[0] if payments else None 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} statuses = {"UNKNOWN": None, "IN_FLIGHT": None, "SUCCEEDED": True, "FAILED": False}
return PaymentStatus(statuses[payment["status"]]) 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 os import getenv
from typing import Optional, Dict from http import HTTPStatus
from requests import get, post from typing import Optional, Dict, AsyncGenerator
from quart import request
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
@ -9,15 +13,16 @@ class LNPayWallet(Wallet):
"""https://docs.lnpay.co/""" """https://docs.lnpay.co/"""
def __init__(self): def __init__(self):
endpoint = getenv("LNPAY_API_ENDPOINT") endpoint = getenv("LNPAY_API_ENDPOINT", "https://lnpay.co/v1")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
self.auth_admin = getenv("LNPAY_ADMIN_KEY") self.auth_admin = getenv("LNPAY_ADMIN_KEY")
self.auth_invoice = getenv("LNPAY_INVOICE_KEY")
self.auth_read = getenv("LNPAY_READ_KEY")
self.auth_api = {"X-Api-Key": getenv("LNPAY_API_KEY")} self.auth_api = {"X-Api-Key": getenv("LNPAY_API_KEY")}
def create_invoice( def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = {"num_satoshis": f"{amount}"} data: Dict = {"num_satoshis": f"{amount}"}
if description_hash: if description_hash:
@ -25,12 +30,17 @@ class LNPayWallet(Wallet):
else: else:
data["memo"] = memo or "" data["memo"] = memo or ""
r = post( r = httpx.post(
url=f"{self.endpoint}/user/wallet/{self.auth_invoice}/invoice", url=f"{self.endpoint}/user/wallet/{self.auth_admin}/invoice",
headers=self.auth_api, headers=self.auth_api,
json=data, 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: if ok:
data = r.json() data = r.json()
@ -39,7 +49,7 @@ class LNPayWallet(Wallet):
return InvoiceResponse(ok, checking_id, payment_request, error_message) return InvoiceResponse(ok, checking_id, payment_request, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse: def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post( r = httpx.post(
url=f"{self.endpoint}/user/wallet/{self.auth_admin}/withdraw", url=f"{self.endpoint}/user/wallet/{self.auth_admin}/withdraw",
headers=self.auth_api, headers=self.auth_api,
json={"payment_request": bolt11}, json={"payment_request": bolt11},
@ -55,10 +65,36 @@ class LNPayWallet(Wallet):
return self.get_payment_status(checking_id) return self.get_payment_status(checking_id)
def get_payment_status(self, checking_id: str) -> PaymentStatus: def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/user/lntx/{checking_id}", headers=self.auth_api) r = httpx.get(
url=f"{self.endpoint}/user/lntx/{checking_id}?fields=settled",
headers=self.auth_api,
)
if not r.ok: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
statuses = {0: None, 1: True, -1: False} statuses = {0: None, 1: True, -1: False}
return PaymentStatus(statuses[r.json()["settled"]]) return PaymentStatus(statuses[r.json()["settled"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.send, receive = trio.open_memory_channel(0)
async for value in receive:
yield value
async def webhook_listener(self):
text: str = await request.get_data()
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 os import getenv
from typing import Optional, Dict from typing import Optional, Dict, AsyncGenerator
from requests import post from requests import post
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
@ -75,3 +76,8 @@ class LntxbotWallet(Wallet):
statuses = {"complete": True, "failed": False, "pending": None, "unknown": None} statuses = {"complete": True, "failed": False, "pending": None, "unknown": None}
return PaymentStatus(statuses[r.json().get("status", "unknown")]) return PaymentStatus(statuses[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 os import getenv
from typing import Optional from typing import Optional, AsyncGenerator
from requests import get, post from quart import request, url_for
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
@ -20,47 +25,77 @@ class OpenNodeWallet(Wallet):
if description_hash: if description_hash:
raise Unsupported("description_hash") raise Unsupported("description_hash")
r = post( r = httpx.post(
url=f"{self.endpoint}/v1/charges", f"{self.endpoint}/v1/charges",
headers=self.auth_invoice, headers=self.auth_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: if r.is_error:
data = r.json()["data"]
checking_id, payment_request = data["id"], data["lightning_invoice"]["payreq"]
else:
error_message = r.json()["message"] error_message = r.json()["message"]
return InvoiceResponse(False, None, None, error_message)
return InvoiceResponse(ok, checking_id, payment_request, error_message) data = r.json()["data"]
checking_id = data["id"]
payment_request = data["lightning_invoice"]["payreq"]
return InvoiceResponse(True, checking_id, payment_request, None)
def pay_invoice(self, bolt11: str) -> PaymentResponse: def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post(url=f"{self.endpoint}/v2/withdrawals", headers=self.auth_admin, json={"type": "ln", "address": bolt11}) r = httpx.post(
ok, checking_id, fee_msat, error_message = r.ok, None, 0, None f"{self.endpoint}/v2/withdrawals", headers=self.auth_admin, json={"type": "ln", "address": bolt11}
)
if r.ok: if r.is_error:
data = r.json()["data"]
checking_id, fee_msat = data["id"], data["fee"] * 1000
else:
error_message = r.json()["message"] error_message = r.json()["message"]
return PaymentResponse(False, None, 0, 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: 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) return PaymentStatus(None)
statuses = {"processing": None, "paid": True, "unpaid": False} statuses = {"processing": None, "paid": True, "unpaid": False}
return PaymentStatus(statuses[r.json()["data"]["status"]]) return PaymentStatus(statuses[r.json()["data"]["status"]])
def get_payment_status(self, checking_id: str) -> PaymentStatus: def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/v1/withdrawal/{checking_id}", headers=self.auth_admin) r = httpx.get(f"{self.endpoint}/v1/withdrawal/{checking_id}", headers=self.auth_admin)
if not r.ok: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
statuses = {"initial": None, "pending": None, "confirmed": True, "error": False, "failed": False} statuses = {"initial": None, "pending": None, "confirmed": True, "error": False, "failed": False}
return PaymentStatus(statuses[r.json()["data"]["status"]]) return PaymentStatus(statuses[r.json()["data"]["status"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.send, receive = trio.open_memory_channel(0)
async for value in receive:
yield value
async def webhook_listener(self):
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 random
import requests import json
import httpx
from os import getenv from os import getenv
from typing import Optional from typing import Optional, AsyncGenerator
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
@ -16,7 +17,7 @@ class UnknownError(Exception):
class SparkWallet(Wallet): class SparkWallet(Wallet):
def __init__(self): def __init__(self):
self.url = getenv("SPARK_URL") self.url = getenv("SPARK_URL").replace("/rpc", "")
self.token = getenv("SPARK_TOKEN") self.token = getenv("SPARK_TOKEN")
def __getattr__(self, key): def __getattr__(self, key):
@ -28,12 +29,12 @@ class SparkWallet(Wallet):
elif kwargs: elif kwargs:
params = 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: try:
data = r.json() data = r.json()
except: except:
raise UnknownError(r.text) raise UnknownError(r.text)
if not r.ok: if r.is_error:
raise SparkError(data["message"]) raise SparkError(data["message"])
return data return data
@ -91,3 +92,14 @@ class SparkWallet(Wallet):
return PaymentStatus(False) return PaymentStatus(False)
return PaymentStatus(None) return PaymentStatus(None)
raise KeyError("supplied an invalid checking_id") raise KeyError("supplied an invalid checking_id")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = self.url + "/stream?access-key=" + self.token
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 from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
@ -17,3 +17,6 @@ class VoidWallet(Wallet):
def get_payment_status(self, checking_id: str) -> PaymentStatus: def get_payment_status(self, checking_id: str) -> PaymentStatus:
raise Unsupported("") raise Unsupported("")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
yield ""

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 aiofiles==0.5.0
async-generator==1.10
attrs==20.2.0
bech32==1.2.0 bech32==1.2.0
bitstring==3.1.7 bitstring==3.1.7
blinker==1.4 blinker==1.4
@ -13,7 +15,7 @@ h11==0.9.0
h2==4.0.0 h2==4.0.0
hpack==4.0.0 hpack==4.0.0
httpcore==0.11.1 httpcore==0.11.1
httpx==0.15.4 httpx==0.15.5
hypercorn==0.11.0 hypercorn==0.11.0
hyperframe==6.0.0 hyperframe==6.0.0
idna==2.10 idna==2.10
@ -22,6 +24,7 @@ jinja2==2.11.2
lnurl==0.3.5 lnurl==0.3.5
markupsafe==1.1.1 markupsafe==1.1.1
marshmallow==3.8.0 marshmallow==3.8.0
outcome==1.0.1
priority==1.3.0 priority==1.3.0
pydantic==1.6.1 pydantic==1.6.1
pyscss==1.3.7 pyscss==1.3.7
@ -29,13 +32,16 @@ python-dotenv==0.14.0
quart==0.13.1 quart==0.13.1
quart-compress==0.2.1 quart-compress==0.2.1
quart-cors==0.3.0 quart-cors==0.3.0
quart-trio==0.5.1
requests==2.24.0 requests==2.24.0
rfc3986==1.4.0 rfc3986==1.4.0
secure==0.2.1 secure==0.2.1
shortuuid==1.0.1 shortuuid==1.0.1
six==1.15.0 six==1.15.0
sniffio==1.1.0 sniffio==1.1.0
sortedcontainers==2.2.2
toml==0.10.1 toml==0.10.1
trio==0.17.0
typing-extensions==3.7.4.3 typing-extensions==3.7.4.3
urllib3==1.25.10 urllib3==1.25.10
werkzeug==1.0.1 werkzeug==1.0.1

View file

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

View file

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