diff --git a/.github/workflows/on-push.yml b/.github/workflows/on-push.yml new file mode 100644 index 00000000..4d008dec --- /dev/null +++ b/.github/workflows/on-push.yml @@ -0,0 +1,58 @@ +name: Docker build on push + +env: + DOCKER_CLI_EXPERIMENTAL: enabled + +on: + push: + branches: + - master + +jobs: + build: + runs-on: ubuntu-20.04 + name: Build and push lnbits image + steps: + - name: Login to Docker Hub + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + - name: Checkout project + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + id: qemu + + - name: Setup Docker buildx action + uses: docker/setup-buildx-action@v1 + id: buildx + + - name: Show available Docker buildx platforms + run: echo ${{ steps.buildx.outputs.platforms }} + + - name: Cache Docker layers + uses: actions/cache@v2 + id: cache + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Run Docker buildx against commit hash + run: | + docker buildx build \ + --cache-from "type=local,src=/tmp/.buildx-cache" \ + --cache-to "type=local,dest=/tmp/.buildx-cache" \ + --platform linux/amd64,linux/arm64,linux/arm/v7 \ + --tag ${{ secrets.DOCKER_USERNAME }}/lnbits:${GITHUB_SHA:0:7} \ + --output "type=registry" ./ + + - name: Run Docker buildx against latest + run: | + docker buildx build \ + --cache-from "type=local,src=/tmp/.buildx-cache" \ + --cache-to "type=local,dest=/tmp/.buildx-cache" \ + --platform linux/amd64,linux/arm64,linux/arm/v7 \ + --tag ${{ secrets.DOCKER_USERNAME }}/lnbits:latest \ + --output "type=registry" ./ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9bd165a7..960fbf75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,48 @@ -FROM python:3.7-slim +# Build image +FROM python:3.7-slim as builder +# Setup virtualenv +ENV VIRTUAL_ENV=/opt/venv +RUN python -m venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +# Install build deps +RUN apt-get update +RUN apt-get install -y --no-install-recommends build-essential + +# Install runtime deps +COPY requirements.txt /tmp/requirements.txt +RUN pip install -r /tmp/requirements.txt + +# Install c-lightning specific deps +RUN pip install pylightning + +# Install LND specific deps +RUN pip install lndgrpc purerpc + +# Production image +FROM python:3.7-slim as lnbits + +# Run as non-root +USER 1000:1000 + +# Copy over virtualenv +ENV VIRTUAL_ENV="/opt/venv" +COPY --from=builder --chown=1000:1000 $VIRTUAL_ENV $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +# Setup Quart +ENV QUART_APP="lnbits.app:create_app()" +ENV QUART_ENV="development" +ENV QUART_DEBUG="true" + +# App +ENV LNBITS_BIND="0.0.0.0:5000" + +# Copy in app source WORKDIR /app -COPY requirements.txt /app/ -RUN pip install --no-cache-dir -q -r requirements.txt -COPY . /app +COPY --chown=1000:1000 lnbits /app/lnbits EXPOSE 5000 + +CMD quart assets && quart migrate && hypercorn -k trio --bind $LNBITS_BIND 'lnbits.app:create_app()' diff --git a/Pipfile b/Pipfile index 6d125eeb..a27dad83 100644 --- a/Pipfile +++ b/Pipfile @@ -24,10 +24,12 @@ quart-trio = "*" trio = "==0.16.0" hypercorn = {extras = ["trio"], version = "*"} sqlalchemy-aio = "*" +pyqrcode = "*" +pypng = "*" [dev-packages] black = "==20.8b1" pytest = "*" pytest-cov = "*" -mypy = "==0.761" +mypy = "latest" pytest-trio = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 8c4d75c5..3bad12ea 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4e34dce2635dc6cc5260a95c959810b290aabaa772a1fe7a9ce02b23fea440c9" + "sha256": "f98f5cc03179f57291aeeca8e0e117ef4f38806176c9d2c0f984f501a5806338" }, "pipfile-spec": 6, "requires": { @@ -81,6 +81,7 @@ "sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296", "sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12", "sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452", + "sha256:854c33dad5ba0fbd6ab69185fec8dab89e13cda6b7d191ba111987df74f38761", "sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea", "sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a", "sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5", @@ -89,6 +90,7 @@ "sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb", "sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b", "sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4", + "sha256:cfc391f4429ee0a9370aa93d812a52e1fee0f37a81861f4fdd1f4fb28e8547c3", "sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7", "sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1", "sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1" @@ -104,10 +106,10 @@ }, "certifi": { "hashes": [ - "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", - "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" ], - "version": "==2020.11.8" + "version": "==2020.12.5" }, "click": { "hashes": [ @@ -127,18 +129,68 @@ }, "environs": { "hashes": [ - "sha256:10dca340bff9c912e99d237905909390365e32723c2785a9f3afa6ef426c53bc", - "sha256:36081033ab34a725c2414f48ee7ec7f7c57e498d8c9255d61fbc7f2d4bf60865" + "sha256:2da44b7c30114415aa858577fa6396ee326fc76a0a60f0f15e8260ba554f19dc", + "sha256:3f6def554abb5455141b540e6e0b72fda3853404f2b0d31658aab1bf95410db3" ], "index": "pypi", - "version": "==9.2.0" + "version": "==9.3.1" + }, + "greenlet": { + "hashes": [ + "sha256:0a77691f0080c9da8dfc81e23f4e3cffa5accf0f5b56478951016d7cfead9196", + "sha256:0ddd77586553e3daf439aa88b6642c5f252f7ef79a39271c25b1d4bf1b7cbb85", + "sha256:111cfd92d78f2af0bc7317452bd93a477128af6327332ebf3c2be7df99566683", + "sha256:122c63ba795fdba4fc19c744df6277d9cfd913ed53d1a286f12189a0265316dd", + "sha256:181300f826625b7fd1182205b830642926f52bd8cdb08b34574c9d5b2b1813f7", + "sha256:1a1ada42a1fd2607d232ae11a7b3195735edaa49ea787a6d9e6a53afaf6f3476", + "sha256:1bb80c71de788b36cefb0c3bb6bfab306ba75073dbde2829c858dc3ad70f867c", + "sha256:1d1d4473ecb1c1d31ce8fd8d91e4da1b1f64d425c1dc965edc4ed2a63cfa67b2", + "sha256:292e801fcb3a0b3a12d8c603c7cf340659ea27fd73c98683e75800d9fd8f704c", + "sha256:2c65320774a8cd5fdb6e117c13afa91c4707548282464a18cf80243cf976b3e6", + "sha256:4365eccd68e72564c776418c53ce3c5af402bc526fe0653722bc89efd85bf12d", + "sha256:5352c15c1d91d22902582e891f27728d8dac3bd5e0ee565b6a9f575355e6d92f", + "sha256:58ca0f078d1c135ecf1879d50711f925ee238fe773dfe44e206d7d126f5bc664", + "sha256:5d4030b04061fdf4cbc446008e238e44936d77a04b2b32f804688ad64197953c", + "sha256:5d69bbd9547d3bc49f8a545db7a0bd69f407badd2ff0f6e1a163680b5841d2b0", + "sha256:5f297cb343114b33a13755032ecf7109b07b9a0020e841d1c3cedff6602cc139", + "sha256:62afad6e5fd70f34d773ffcbb7c22657e1d46d7fd7c95a43361de979f0a45aef", + "sha256:647ba1df86d025f5a34043451d7c4a9f05f240bee06277a524daad11f997d1e7", + "sha256:719e169c79255816cdcf6dccd9ed2d089a72a9f6c42273aae12d55e8d35bdcf8", + "sha256:7cd5a237f241f2764324396e06298b5dee0df580cf06ef4ada0ff9bff851286c", + "sha256:875d4c60a6299f55df1c3bb870ebe6dcb7db28c165ab9ea6cdc5d5af36bb33ce", + "sha256:90b6a25841488cf2cb1c8623a53e6879573010a669455046df5f029d93db51b7", + "sha256:94620ed996a7632723a424bccb84b07e7b861ab7bb06a5aeb041c111dd723d36", + "sha256:b5f1b333015d53d4b381745f5de842f19fe59728b65f0fbb662dafbe2018c3a5", + "sha256:c5b22b31c947ad8b6964d4ed66776bcae986f73669ba50620162ba7c832a6b6a", + "sha256:c93d1a71c3fe222308939b2e516c07f35a849c5047f0197442a4d6fbcb4128ee", + "sha256:cdb90267650c1edb54459cdb51dab865f6c6594c3a47ebd441bc493360c7af70", + "sha256:cfd06e0f0cc8db2a854137bd79154b61ecd940dce96fad0cba23fe31de0b793c", + "sha256:d3789c1c394944084b5e57c192889985a9f23bd985f6d15728c745d380318128", + "sha256:da7d09ad0f24270b20f77d56934e196e982af0d0a2446120cb772be4e060e1a2", + "sha256:df3e83323268594fa9755480a442cabfe8d82b21aba815a71acf1bb6c1776218", + "sha256:df8053867c831b2643b2c489fe1d62049a98566b1646b194cc815f13e27b90df", + "sha256:e1128e022d8dce375362e063754e129750323b67454cac5600008aad9f54139e", + "sha256:e6e9fdaf6c90d02b95e6b0709aeb1aba5affbbb9ccaea5502f8638e4323206be", + "sha256:eac8803c9ad1817ce3d8d15d1bb82c2da3feda6bee1153eec5c58fa6e5d3f770", + "sha256:eb333b90036358a0e2c57373f72e7648d7207b76ef0bd00a4f7daad1f79f5203", + "sha256:ed1d1351f05e795a527abc04a0d82e9aecd3bdf9f46662c36ff47b0b00ecaf06", + "sha256:f3dc68272990849132d6698f7dc6df2ab62a88b0d36e54702a8fd16c0490e44f", + "sha256:f59eded163d9752fd49978e0bab7a1ff21b1b8d25c05f0995d140cc08ac83379", + "sha256:f5e2d36c86c7b03c94b8459c3bd2c9fe2c7dab4b258b8885617d44a22e453fb7", + "sha256:f6f65bf54215e4ebf6b01e4bb94c49180a589573df643735107056f7a910275b", + "sha256:f8450d5ef759dbe59f84f2c9f77491bb3d3c44bc1a573746daf086e70b14c243", + "sha256:f97d83049715fd9dec7911860ecf0e17b48d8725de01e45de07d8ac0bd5bc378" + ], + "markers": "python_version >= '3'", + "version": "==1.0.0" }, "h11": { "hashes": [ - "sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab", - "sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87" + "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", + "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" ], - "version": "==0.11.0" + "markers": "python_version >= '3.6'", + "version": "==0.12.0" }, "h2": { "hashes": [ @@ -158,30 +210,30 @@ }, "httpcore": { "hashes": [ - "sha256:420700af11db658c782f7e8fda34f9dcd95e3ee93944dd97d78cb70247e0cd06", - "sha256:dd1d762d4f7c2702149d06be2597c35fb154c5eff9789a8c5823fbcf4d2978d6" + "sha256:37ae835fb370049b2030c3290e12ed298bf1473c41bb72ca4aa78681eba9b7c9", + "sha256:93e822cd16c32016b414b789aeff4e855d0ccbfc51df563ee34d4dbadbb3bcdc" ], "markers": "python_version >= '3.6'", - "version": "==0.12.2" + "version": "==0.12.3" }, "httpx": { "hashes": [ - "sha256:126424c279c842738805974687e0518a94c7ae8d140cd65b9c4f77ac46ffa537", - "sha256:9cffb8ba31fac6536f2c8cde30df859013f59e4bcc5b8d43901cb3654a8e0a5b" + "sha256:cc2a55188e4b25272d2bcd46379d300f632045de4377682aa98a8a6069d55967", + "sha256:d379653bd457e8257eb0df99cb94557e4aac441b7ba948e333be969298cac272" ], "index": "pypi", - "version": "==0.16.1" + "version": "==0.17.1" }, "hypercorn": { "extras": [ "trio" ], "hashes": [ - "sha256:81c69dd84a87b8e8b3ebf06ef5dd92836a8238f0ac65ded3d86befb8ba9acfeb", - "sha256:e3f317d6d64d15ce589f49e4f5057947259fa35332d169e62cb060e9997189e4" + "sha256:5ba1e719c521080abd698ff5781a2331e34ef50fc1c89a50960538115a896a9a", + "sha256:8007c10f81566920f8ae12c0e26e146f94ca70506da964b5a727ad610aa1d821" ], "index": "pypi", - "version": "==0.11.1" + "version": "==0.11.2" }, "hyperframe": { "hashes": [ @@ -193,10 +245,10 @@ }, "idna": { "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", + "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" ], - "version": "==2.10" + "version": "==3.1" }, "itsdangerous": { "hashes": [ @@ -208,11 +260,11 @@ }, "jinja2": { "hashes": [ - "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", - "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" + "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", + "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" ], "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.3" }, "lnurl": { "hashes": [ @@ -228,8 +280,12 @@ "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", + "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f", + "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39", "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014", + "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f", "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", @@ -238,35 +294,50 @@ "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85", + "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1", "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850", + "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0", "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb", "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1", + "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2", "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7", "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8", "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193", "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b", "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5", + "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c", + "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032", "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", + "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "marshmallow": { "hashes": [ - "sha256:73facc37462dfc0b27f571bdaffbef7709e19f7a616beb3802ea425b07843f4e", - "sha256:e26763201474b588d144dae9a32bdd945cd26a06c943bc746a6882e850475378" + "sha256:4ab2fdb7f36eb61c3665da67a7ce281c8900db08d72ba6bf0e695828253581f7", + "sha256:eca81d53aa4aafbc0e20566973d0d2e50ce8bf0ee15165bb799bec0df1e50177" ], "markers": "python_version >= '3.5'", - "version": "==3.9.1" + "version": "==3.10.0" }, "outcome": { "hashes": [ @@ -285,31 +356,46 @@ }, "pydantic": { "hashes": [ - "sha256:01f0291f4951580f320f7ae3f2ecaf0044cdebcc9b45c5f882a7e84453362420", - "sha256:0fe8b45d31ae53d74a6aa0bf801587bd49970070eac6a6326f9fa2a302703b8a", - "sha256:2182ba2a9290964b278bcc07a8d24207de709125d520efec9ad6fa6f92ee058d", - "sha256:2c1673633ad1eea78b1c5c420a47cd48717d2ef214c8230d96ca2591e9e00958", - "sha256:388c0c26c574ff49bad7d0fd6ed82fbccd86a0473fa3900397d3354c533d6ebb", - "sha256:4ba6b903e1b7bd3eb5df0e78d7364b7e831ed8b4cd781ebc3c4f1077fbcb72a4", - "sha256:6665f7ab7fbbf4d3c1040925ff4d42d7549a8c15fe041164adfe4fc2134d4cce", - "sha256:95d4410c4e429480c736bba0db6cce5aaa311304aea685ebcf9ee47571bfd7c8", - "sha256:a2fc7bf77ed4a7a961d7684afe177ff59971828141e608f142e4af858e07dddc", - "sha256:a3c274c49930dc047a75ecc865e435f3df89715c775db75ddb0186804d9b04d0", - "sha256:ab1d5e4d8de00575957e1c982b951bffaedd3204ddd24694e3baca3332e53a23", - "sha256:b11fc9530bf0698c8014b2bdb3bbc50243e82a7fa2577c8cfba660bcc819e768", - "sha256:b9572c0db13c8658b4a4cb705dcaae6983aeb9842248b36761b3fbc9010b740f", - "sha256:c68b5edf4da53c98bb1ccb556ae8f655575cb2e676aef066c12b08c724a3f1a1", - "sha256:c8200aecbd1fb914e1bd061d71a4d1d79ecb553165296af0c14989b89e90d09b", - "sha256:c9760d1556ec59ff745f88269a8f357e2b7afc75c556b3a87b8dda5bc62da8ba", - "sha256:ce2d452961352ba229fe1e0b925b41c0c37128f08dddb788d0fd73fd87ea0f66", - "sha256:dfaa6ed1d509b5aef4142084206584280bb6e9014f01df931ec6febdad5b200a", - "sha256:e5fece30e80087d9b7986104e2ac150647ec1658c4789c89893b03b100ca3164", - "sha256:f045cf7afb3352a03bc6cb993578a34560ac24c5d004fa33c76efec6ada1361a", - "sha256:f83f679e727742b0c465e7ef992d6da4a7e5268b8edd8fdaf5303276374bef52", - "sha256:fc21a37ff3f545de80b166e1735c4172b41b017948a3fb2d5e2f03c219eac50a" + "sha256:0c40162796fc8d0aa744875b60e4dc36834db9f2a25dbf9ba9664b1915a23850", + "sha256:20d42f1be7c7acc352b3d09b0cf505a9fab9deb93125061b376fbe1f06a5459f", + "sha256:2287ebff0018eec3cc69b1d09d4b7cebf277726fa1bd96b45806283c1d808683", + "sha256:258576f2d997ee4573469633592e8b99aa13bda182fcc28e875f866016c8e07e", + "sha256:26cf3cb2e68ec6c0cfcb6293e69fb3450c5fd1ace87f46b64f678b0d29eac4c3", + "sha256:2f2736d9a996b976cfdfe52455ad27462308c9d3d0ae21a2aa8b4cd1a78f47b9", + "sha256:3114d74329873af0a0e8004627f5389f3bb27f956b965ddd3e355fe984a1789c", + "sha256:3bbd023c981cbe26e6e21c8d2ce78485f85c2e77f7bab5ec15b7d2a1f491918f", + "sha256:3bcb9d7e1f9849a6bdbd027aabb3a06414abd6068cb3b21c49427956cce5038a", + "sha256:4bbc47cf7925c86a345d03b07086696ed916c7663cb76aa409edaa54546e53e2", + "sha256:6388ef4ef1435364c8cc9a8192238aed030595e873d8462447ccef2e17387125", + "sha256:830ef1a148012b640186bf4d9789a206c56071ff38f2460a32ae67ca21880eb8", + "sha256:8fbb677e4e89c8ab3d450df7b1d9caed23f254072e8597c33279460eeae59b99", + "sha256:c17a0b35c854049e67c68b48d55e026c84f35593c66d69b278b8b49e2484346f", + "sha256:dd4888b300769ecec194ca8f2699415f5f7760365ddbe243d4fd6581485fa5f0", + "sha256:dde4ca368e82791de97c2ec019681ffb437728090c0ff0c3852708cf923e0c7d", + "sha256:e3f8790c47ac42549dc8b045a67b0ca371c7f66e73040d0197ce6172b385e520", + "sha256:e8bc082afef97c5fd3903d05c6f7bb3a6af9fc18631b4cc9fedeb4720efb0c58", + "sha256:eb8ccf12295113ce0de38f80b25f736d62f0a8d87c6b88aca645f168f9c78771", + "sha256:fb77f7a7e111db1832ae3f8f44203691e15b1fa7e5a1cb9691d4e2659aee41c4", + "sha256:fbfb608febde1afd4743c6822c19060a8dbdd3eb30f98e36061ba4973308059e", + "sha256:fff29fe54ec419338c522b908154a2efabeee4f483e48990f87e189661f31ce3" ], - "markers": "python_version >= '3.6'", - "version": "==1.7.2" + "markers": "python_full_version >= '3.6.1'", + "version": "==1.8.1" + }, + "pypng": { + "hashes": [ + "sha256:1032833440c91bafee38a42c38c02d00431b24c42927feb3e63b104d8550170b" + ], + "index": "pypi", + "version": "==0.0.20" + }, + "pyqrcode": { + "hashes": [ + "sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6", + "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5" + ], + "index": "pypi", + "version": "==1.2.1" }, "pyscss": { "hashes": [ @@ -320,18 +406,18 @@ }, "python-dotenv": { "hashes": [ - "sha256:0c8d1b80d1a1e91717ea7d526178e3882732420b03f08afea0406db6402e220e", - "sha256:587825ed60b1711daea4832cf37524dfd404325b7db5e25ebe88c495c9f807a0" + "sha256:31d752f5b748f4e292448c9a0cac6a08ed5e6f4cefab85044462dcad56905cec", + "sha256:9fa413c37d4652d3fa02fea0ff465c384f5db75eab259c4fc5d0c5b8bf20edd4" ], - "version": "==0.15.0" + "version": "==0.16.0" }, "quart": { "hashes": [ - "sha256:9c634e4c1e4b21b824003c676de1583581258c72b0ac4d2ba747db846e97ff56", - "sha256:d885d782edd9d5dcfd2c4a56e020db3b82493d4c3950f91c221b7d88d239ac93" + "sha256:429c5b4ff27e1d2f9ca0aacc38f6aba0ff49b38b815448bf24b613d3de12ea02", + "sha256:7b13786e07541cc9ce1466fdc6a6ccd5f36eb39118edd25a42d617593cd17707" ], "index": "pypi", - "version": "==0.13.1" + "version": "==0.14.1" }, "quart-compress": { "hashes": [ @@ -343,26 +429,27 @@ }, "quart-cors": { "hashes": [ - "sha256:020a17d504264db86cada3c1335ef174af28b33f57cee321ddc46d69c33d5c8e", - "sha256:c08bdb326219b6c186d19ed6a97a7fd02de8fe36c7856af889494c69b525c53c" + "sha256:0ea23ea8db2c21835f6698b91a09d99ab59f98f8d90a2a739475ef0409591573", + "sha256:e526e9929934ad31301853efe357a3bd2e08c3282aff37184fa8671ed854f052" ], "index": "pypi", - "version": "==0.3.0" + "version": "==0.4.0" }, "quart-trio": { "hashes": [ - "sha256:8262e82d01ff63a1e74f9a95e5980f9658bfd5facf119d99e11c7bfe23427d69", - "sha256:ce63f8b21c6795579f0206138ee67487259359d8e9341b2924fa635f7672de32" + "sha256:1e7fce0df41afc3038bf0431b20614f90984de50341b19f9d4d3b9ba1ac7574a", + "sha256:933e3c18e232ece30ccbac7579fdc5f62f2f9c79c3273d6c341f5a1686791eb1" ], "index": "pypi", - "version": "==0.6.0" + "version": "==0.7.0" }, "represent": { "hashes": [ - "sha256:293dfec8b2e9e2150a21a49bfec2cd009ecb600c8c04f9186d2ad222c3cef78a", - "sha256:6000c24f317dbf8b57a116ce4d7e4459fc5900af6a2915c9a2d74456bcc33d3c" + "sha256:026c0de2ee8385d1255b9c2426cd4f03fe9177ac94c09979bc601946c8493aa0", + "sha256:99142650756ef1998ce0661568f54a47dac8c638fb27e3816c02536575dbba8c" ], - "version": "==1.6.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.6.0.post0" }, "rfc3986": { "extras": [ @@ -395,7 +482,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "sniffio": { @@ -415,47 +502,43 @@ }, "sqlalchemy": { "hashes": [ - "sha256:009e8388d4d551a2107632921320886650b46332f61dc935e70c8bcf37d8e0d6", - "sha256:0157c269701d88f5faf1fa0e4560e4d814f210c01a5b55df3cab95e9346a8bcc", - "sha256:0a92745bb1ebbcb3985ed7bda379b94627f0edbc6c82e9e4bac4fb5647ae609a", - "sha256:0cca1844ba870e81c03633a99aa3dc62256fb96323431a5dec7d4e503c26372d", - "sha256:166917a729b9226decff29416f212c516227c2eb8a9c9f920d69ced24e30109f", - "sha256:1f5f369202912be72fdf9a8f25067a5ece31a2b38507bb869306f173336348da", - "sha256:2909dffe5c9a615b7e6c92d1ac2d31e3026dc436440a4f750f4749d114d88ceb", - "sha256:2b5dafed97f778e9901b79cc01b88d39c605e0545b4541f2551a2fd785adc15b", - "sha256:2e9bd5b23bba8ae8ce4219c9333974ff5e103c857d9ff0e4b73dc4cb244c7d86", - "sha256:3aa6d45e149a16aa1f0c46816397e12313d5e37f22205c26e06975e150ffcf2a", - "sha256:4bdbdb8ca577c6c366d15791747c1de6ab14529115a2eb52774240c412a7b403", - "sha256:53fd857c6c8ffc0aa6a5a3a2619f6a74247e42ec9e46b836a8ffa4abe7aab327", - "sha256:5cdfe54c1e37279dc70d92815464b77cd8ee30725adc9350f06074f91dbfeed2", - "sha256:5d92c18458a4aa27497a986038d5d797b5279268a2de303cd00910658e8d149c", - "sha256:632b32183c0cb0053194a4085c304bc2320e5299f77e3024556fa2aa395c2a8b", - "sha256:7c735c7a6db8ee9554a3935e741cf288f7dcbe8706320251eb38c412e6a4281d", - "sha256:7cd40cb4bc50d9e87b3540b23df6e6b24821ba7e1f305c1492b0806c33dbdbec", - "sha256:84f0ac4a09971536b38cc5d515d6add7926a7e13baa25135a1dbb6afa351a376", - "sha256:8dcbf377529a9af167cbfc5b8acec0fadd7c2357fc282a1494c222d3abfc9629", - "sha256:950f0e17ffba7a7ceb0dd056567bc5ade22a11a75920b0e8298865dc28c0eff6", - "sha256:9e379674728f43a0cd95c423ac0e95262500f9bfd81d33b999daa8ea1756d162", - "sha256:b15002b9788ffe84e42baffc334739d3b68008a973d65fad0a410ca5d0531980", - "sha256:b6f036ecc017ec2e2cc2a40615b41850dc7aaaea6a932628c0afc73ab98ba3fb", - "sha256:bad73f9888d30f9e1d57ac8829f8a12091bdee4949b91db279569774a866a18e", - "sha256:bbc58fca72ce45a64bb02b87f73df58e29848b693869e58bd890b2ddbb42d83b", - "sha256:bca4d367a725694dae3dfdc86cf1d1622b9f414e70bd19651f5ac4fb3aa96d61", - "sha256:be41d5de7a8e241864189b7530ca4aaf56a5204332caa70555c2d96379e18079", - "sha256:bf53d8dddfc3e53a5bda65f7f4aa40fae306843641e3e8e701c18a5609471edf", - "sha256:c092fe282de83d48e64d306b4bce03114859cdbfe19bf8a978a78a0d44ddadb1", - "sha256:c3ab23ee9674336654bf9cac30eb75ac6acb9150dc4b1391bec533a7a4126471", - "sha256:ce64a44c867d128ab8e675f587aae7f61bd2db836a3c4ba522d884cd7c298a77", - "sha256:d05cef4a164b44ffda58200efcb22355350979e000828479971ebca49b82ddb1", - "sha256:d2f25c7f410338d31666d7ddedfa67570900e248b940d186b48461bd4e5569a1", - "sha256:d3b709d64b5cf064972b3763b47139e4a0dc4ae28a36437757f7663f67b99710", - "sha256:e32e3455db14602b6117f0f422f46bc297a3853ae2c322ecd1e2c4c04daf6ed5", - "sha256:ed53209b5f0f383acb49a927179fa51a6e2259878e164273ebc6815f3a752465", - "sha256:f605f348f4e6a2ba00acb3399c71d213b92f27f2383fc4abebf7a37368c12142", - "sha256:fcdb3755a7c355bc29df1b5e6fb8226d5c8b90551d202d69d0076a8a5649d68b" + "sha256:0096305b3e0912c59d8308f55d17544b3e5c1787f5ad8ef9cd75084136bcba9c", + "sha256:106fd3da313390dffe6ca156e5b7244293d6f4bfd389bcb315771c7addb5f3b3", + "sha256:1b30b71ea7c0f854d1b31549816694d8c435c9b5cce44da140b473544bb48a6b", + "sha256:228fe0cc700748ccc7a9a430896a77dfaa8a1035874e540961589e31f31cabe1", + "sha256:22faab9884c46ea2c00d5457a6a23375e0b4ab5257b72a27c8b979d4f677d4cf", + "sha256:3280c283e85e5c7b95c7be75b2df765d4bb13a01be36552826557bb4177d2bdf", + "sha256:33a2b756bd8f7022c24a16228071dea39cf6f21f62732a5307b6ebcef084bf16", + "sha256:409f3cd35f99592d0ceb1ee2e13c24b3083109e0f80096aae36000e5988aa24a", + "sha256:41a6dc66714c7dddf210dc8652d19bb2a55364c21038ca77312500014271aa67", + "sha256:4aa4de9bd3ae5e46f7395aa769303722e7174795ae83dd78302d849fcbc7513d", + "sha256:52c2512914bdeb3ce7957e2597b6a9d4a3dd3b3177c32ccf481908d6e59384ae", + "sha256:5da7f97893631d060c4590e531784f5eaf64bb3e6002804ee8a96d9c91cd1885", + "sha256:655b35725f1478bb6f797336acc803ddbb4c693816b3663a6fd94ad454eb056a", + "sha256:755aed46915e20c0b317a4124251f31682dcc7a43984d771352f6863ea11cd9c", + "sha256:81920161039cca14dac30378713c472a0ac5e783b2077984d6f8ec6f2d824356", + "sha256:83e65d8826bc649f8af556588555b744d4b9cfc0fcda8f3ddd08fe43c656e459", + "sha256:8761759028eb7754b76ca153e613bdea0fb6f8107557e57c60616a7212e2a297", + "sha256:8cf28097524b7fff3526df9154abcdbed0c4e434d4c4e6787e3d4fc33e7deb6a", + "sha256:92cca0c8757ac9a8a53cc800ea0f04a4f6c346376bc4cb878e4a6aed6f19d18d", + "sha256:96e68231f7115f5acb1bb51ccec26351bc155fedf835d7625fa203a43c8a3762", + "sha256:9852a7b4feee4c7de4b7541fa8a72ab36a5dad7942c58006e76ffe59c0f8efec", + "sha256:a719b80b41a900bbcec3cc248616394ebd134043ce5e62185270d785d8a184d3", + "sha256:a91fa4189f66af9644fde50740c5134689dc01c6c5edf04af6eafa3225ae110c", + "sha256:aa529647a3770293f392dff40466344a5d142fe66a2bbec465247a05d695eced", + "sha256:ad2d9fc0ffba476cf069cea558527bc23e1ced24ec6c8badab8aa63cbde56b07", + "sha256:b445fe8f043288178bc7d4adda49a505de86641864d50493d3fad10e0711cbff", + "sha256:b529f285b04094d458e811147a320019397909265eef1d1aa9dc6ecac0ad240c", + "sha256:bce476fd66aeaeb1a155f97838233d95fccd2c611da4d6b1cb4b6205435e5326", + "sha256:be1df71f9a06730b2a7213b68d6c465130a82305789462e375cf87037b181af3", + "sha256:c063277efd89c7f755480ff80f87828c9a68afb0fdc6d79462b9e474301fded3", + "sha256:d157a87dcd861eae04cd9b19cac535451719397fcae7b6f870688d8fb69d84f9", + "sha256:d34afc46b9fe3025b8db7ade6876bf80668918c5cdcdae067aaec348b5daa821", + "sha256:e337983564e09857a7a687dfa7adfaf85f59ed9e885d30081e13aea792d6abf7", + "sha256:e8b24bb68e981b6a2a8845d6d0f85891564d38562fc338170338ef90a221241e" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.3.20" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==1.4.3" }, "sqlalchemy-aio": { "hashes": [ @@ -470,7 +553,7 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, "trio": { @@ -548,50 +631,68 @@ }, "coverage": { "hashes": [ - "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", - "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", - "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", - "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", - "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", - "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", - "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", - "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", - "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", - "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", - "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", - "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", - "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", - "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", - "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", - "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", - "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", - "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", - "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", - "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", - "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", - "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", - "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", - "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", - "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", - "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", - "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", - "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", - "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", - "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", - "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", - "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", - "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", - "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" + "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c", + "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6", + "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45", + "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a", + "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03", + "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529", + "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a", + "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a", + "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2", + "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6", + "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759", + "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53", + "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a", + "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4", + "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff", + "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502", + "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793", + "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb", + "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905", + "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821", + "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b", + "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81", + "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0", + "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b", + "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3", + "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184", + "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701", + "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a", + "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82", + "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638", + "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5", + "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083", + "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6", + "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90", + "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465", + "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a", + "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3", + "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e", + "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066", + "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf", + "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b", + "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae", + "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669", + "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873", + "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b", + "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6", + "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb", + "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160", + "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c", + "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079", + "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", + "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" ], "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.5" }, "idna": { "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", + "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" ], - "version": "==2.10" + "version": "==3.1" }, "iniconfig": { "hashes": [ @@ -602,23 +703,31 @@ }, "mypy": { "hashes": [ - "sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a", - "sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7", - "sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2", - "sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474", - "sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0", - "sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217", - "sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749", - "sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6", - "sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf", - "sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36", - "sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b", - "sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72", - "sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1", - "sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1" + "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e", + "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064", + "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c", + "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4", + "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97", + "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df", + "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8", + "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a", + "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56", + "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7", + "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6", + "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5", + "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a", + "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521", + "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564", + "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49", + "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66", + "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a", + "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119", + "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506", + "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c", + "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb" ], "index": "pypi", - "version": "==0.761" + "version": "==0.812" }, "mypy-extensions": { "hashes": [ @@ -637,11 +746,11 @@ }, "packaging": { "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" + "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", + "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.4" + "version": "==20.9" }, "pathspec": { "hashes": [ @@ -660,35 +769,35 @@ }, "py": { "hashes": [ - "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", - "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" + "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", + "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.9.0" + "version": "==1.10.0" }, "pyparsing": { "hashes": [ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "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:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe", - "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e" + "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9", + "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839" ], "index": "pypi", - "version": "==6.1.2" + "version": "==6.2.2" }, "pytest-cov": { "hashes": [ - "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191", - "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e" + "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7", + "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da" ], "index": "pypi", - "version": "==2.10.1" + "version": "==2.11.1" }, "pytest-trio": { "hashes": [ @@ -699,57 +808,49 @@ }, "regex": { "hashes": [ - "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538", - "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4", - "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc", - "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa", - "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444", - "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1", - "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af", - "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8", - "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9", - "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88", - "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba", - "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364", - "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e", - "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7", - "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0", - "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31", - "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683", - "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee", - "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b", - "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884", - "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c", - "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e", - "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562", - "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85", - "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c", - "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6", - "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d", - "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b", - "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70", - "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b", - "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b", - "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f", - "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0", - "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5", - "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5", - "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f", - "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e", - "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512", - "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d", - "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917", - "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f" + "sha256:07ef35301b4484bce843831e7039a84e19d8d33b3f8b2f9aab86c376813d0139", + "sha256:13f50969028e81765ed2a1c5fcfdc246c245cf8d47986d5172e82ab1a0c42ee5", + "sha256:14de88eda0976020528efc92d0a1f8830e2fb0de2ae6005a6fc4e062553031fa", + "sha256:159fac1a4731409c830d32913f13f68346d6b8e39650ed5d704a9ce2f9ef9cb3", + "sha256:18e25e0afe1cf0f62781a150c1454b2113785401ba285c745acf10c8ca8917df", + "sha256:201e2619a77b21a7780580ab7b5ce43835e242d3e20fef50f66a8df0542e437f", + "sha256:360a01b5fa2ad35b3113ae0c07fb544ad180603fa3b1f074f52d98c1096fa15e", + "sha256:39c44532d0e4f1639a89e52355b949573e1e2c5116106a395642cbbae0ff9bcd", + "sha256:3d9356add82cff75413bec360c1eca3e58db4a9f5dafa1f19650958a81e3249d", + "sha256:3d9a7e215e02bd7646a91fb8bcba30bc55fd42a719d6b35cf80e5bae31d9134e", + "sha256:4651f839dbde0816798e698626af6a2469eee6d9964824bb5386091255a1694f", + "sha256:486a5f8e11e1f5bbfcad87f7c7745eb14796642323e7e1829a331f87a713daaa", + "sha256:4b8a1fb724904139149a43e172850f35aa6ea97fb0545244dc0b805e0154ed68", + "sha256:4c0788010a93ace8a174d73e7c6c9d3e6e3b7ad99a453c8ee8c975ddd9965643", + "sha256:4c2e364491406b7888c2ad4428245fc56c327e34a5dfe58fd40df272b3c3dab3", + "sha256:575a832e09d237ae5fedb825a7a5bc6a116090dd57d6417d4f3b75121c73e3be", + "sha256:5770a51180d85ea468234bc7987f5597803a4c3d7463e7323322fe4a1b181578", + "sha256:633497504e2a485a70a3268d4fc403fe3063a50a50eed1039083e9471ad0101c", + "sha256:63f3ca8451e5ff7133ffbec9eda641aeab2001be1a01878990f6c87e3c44b9d5", + "sha256:709f65bb2fa9825f09892617d01246002097f8f9b6dde8d1bb4083cf554701ba", + "sha256:808404898e9a765e4058bf3d7607d0629000e0a14a6782ccbb089296b76fa8fe", + "sha256:882f53afe31ef0425b405a3f601c0009b44206ea7f55ee1c606aad3cc213a52c", + "sha256:8bd4f91f3fb1c9b1380d6894bd5b4a519409135bec14c0c80151e58394a4e88a", + "sha256:8e65e3e4c6feadf6770e2ad89ad3deb524bcb03d8dc679f381d0568c024e0deb", + "sha256:976a54d44fd043d958a69b18705a910a8376196c6b6ee5f2596ffc11bff4420d", + "sha256:a0d04128e005142260de3733591ddf476e4902c0c23c1af237d9acf3c96e1b38", + "sha256:a0df9a0ad2aad49ea3c7f65edd2ffb3d5c59589b85992a6006354f6fb109bb18", + "sha256:a2ee026f4156789df8644d23ef423e6194fad0bc53575534101bb1de5d67e8ce", + "sha256:a59a2ee329b3de764b21495d78c92ab00b4ea79acef0f7ae8c1067f773570afa", + "sha256:b97ec5d299c10d96617cc851b2e0f81ba5d9d6248413cd374ef7f3a8871ee4a6", + "sha256:b98bc9db003f1079caf07b610377ed1ac2e2c11acc2bea4892e28cc5b509d8d5", + "sha256:b9d8d286c53fe0cbc6d20bf3d583cabcd1499d89034524e3b94c93a5ab85ca90", + "sha256:bcd945175c29a672f13fce13a11893556cd440e37c1b643d6eeab1988c8b209c", + "sha256:c66221e947d7207457f8b6f42b12f613b09efa9669f65a587a2a71f6a0e4d106", + "sha256:c782da0e45aff131f0bed6e66fbcfa589ff2862fc719b83a88640daa01a5aff7", + "sha256:cb4ee827857a5ad9b8ae34d3c8cc51151cb4a3fe082c12ec20ec73e63cc7c6f0", + "sha256:d47d359545b0ccad29d572ecd52c9da945de7cd6cf9c0cfcb0269f76d3555689", + "sha256:dc9963aacb7da5177e40874585d7407c0f93fb9d7518ec58b86e562f633f36cd", + "sha256:ea2f41445852c660ba7c3ebf7d70b3779b20d9ca8ba54485a17740db49f46932", + "sha256:f5d0c921c99297354cecc5a416ee4280bd3f20fd81b9fb671ca6be71499c3fdf", + "sha256:f85d6f41e34f6a2d1607e312820971872944f1661a73d33e1e82d35ea3305e14" ], - "version": "==2020.11.13" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==1.15.0" + "version": "==2021.3.17" }, "sniffio": { "hashes": [ @@ -771,7 +872,7 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, "trio": { @@ -784,38 +885,38 @@ }, "typed-ast": { "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072", - "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298", - "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1", + "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d", + "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6", + "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd", + "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37", + "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151", + "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07", + "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440", + "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70", + "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496", + "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea", + "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400", + "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc", + "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606", + "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc", + "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581", + "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412", + "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a", + "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2", + "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787", + "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f", + "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937", + "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64", + "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487", + "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b", + "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41", + "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a", + "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3", + "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166", + "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10" ], - "version": "==1.4.1" + "version": "==1.4.2" }, "typing-extensions": { "hashes": [ diff --git a/README.md b/README.md index a5a1a1ff..3fce0ab7 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an * Fallback wallet for the LNURL scheme * Instant wallet for LN demonstrations -The wallet can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularily. +LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularily. See [lnbits.org](https://lnbits.org) for more detailed documentation. @@ -68,7 +68,7 @@ Wallets can be easily generated and given out to people at events (one click mul  -## Tip me +## Tip us If you like this project and might even use or extend it, why not [send some tip love](https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)! diff --git a/docs/devs/installation.md b/docs/devs/installation.md index 224aedd8..81ae2a4c 100644 --- a/docs/devs/installation.md +++ b/docs/devs/installation.md @@ -23,6 +23,9 @@ $ pipenv shell $ pipenv install --dev ``` +If any of the modules fails to install, try checking and upgrading your setupTool module. +`pip install -U setuptools` + If you wish to use a version of Python higher than 3.7: ```sh diff --git a/lnbits/__main__.py b/lnbits/__main__.py index 89fc6163..fa75231c 100644 --- a/lnbits/__main__.py +++ b/lnbits/__main__.py @@ -10,7 +10,14 @@ from .app import create_app app = create_app() -from .settings import LNBITS_SITE_TITLE, SERVICE_FEE, DEBUG, LNBITS_DATA_FOLDER, WALLET, LNBITS_COMMIT +from .settings import ( + LNBITS_SITE_TITLE, + SERVICE_FEE, + DEBUG, + LNBITS_DATA_FOLDER, + WALLET, + LNBITS_COMMIT, +) print( f"""Starting LNbits with diff --git a/lnbits/app.py b/lnbits/app.py index b5fe71bd..cd700f5c 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -1,3 +1,4 @@ +import sys import importlib import warnings @@ -9,12 +10,24 @@ from secure import SecureHeaders # type: ignore from .commands import db_migrate, handle_assets from .core import core_app -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 .tasks import run_deferred_async, invoice_listener, internal_invoice_listener, webhook_handler, grab_app_for_later +from .tasks import ( + run_deferred_async, + check_pending_payments, + invoice_listener, + internal_invoice_listener, + webhook_handler, + grab_app_for_later, +) from .settings import WALLET -secure_headers = SecureHeaders(hsts=False,xfo=False) +secure_headers = SecureHeaders(hsts=False, xfo=False) def create_app(config_object="lnbits.settings") -> QuartTrio: @@ -43,14 +56,18 @@ def create_app(config_object="lnbits.settings") -> QuartTrio: def check_funding_source(app: QuartTrio) -> None: @app.before_serving async def check_wallet_status(): - error_message, balance = WALLET.status() + error_message, balance = await WALLET.status() if error_message: warnings.warn( f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", RuntimeWarning, ) + + sys.exit(4) else: - print(f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat.") + print( + f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat." + ) def register_blueprints(app: QuartTrio) -> None: @@ -62,13 +79,11 @@ def register_blueprints(app: QuartTrio) -> None: ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}") bp = getattr(ext_module, f"{ext.code}_ext") - @bp.teardown_request - async def after_request(exc): - await ext_module.db.close_session() - app.register_blueprint(bp, url_prefix=f"/{ext.code}") 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: QuartTrio): @@ -103,12 +118,6 @@ def register_request_hooks(app: QuartTrio): async def before_request(): g.nursery = app.nursery - @app.teardown_request - async def after_request(exc): - from lnbits.core import db - - await db.close_session() - @app.after_request async def set_secure_headers(response): secure_headers.quart(response) @@ -123,8 +132,9 @@ def register_async_tasks(app): @app.before_serving async def listeners(): run_deferred_async(app.nursery) - app.nursery.start_soon(invoice_listener) - app.nursery.start_soon(internal_invoice_listener) + app.nursery.start_soon(check_pending_payments) + app.nursery.start_soon(invoice_listener, app.nursery) + app.nursery.start_soon(internal_invoice_listener, app.nursery) @app.after_serving async def stop_listeners(): diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index 1be351be..6acc6db7 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -106,7 +106,9 @@ def decode(pr: str) -> Invoice: key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1) key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string) else: - keys = VerifyingKey.from_public_key_recovery(sig, message, SECP256k1, hashlib.sha256) + keys = VerifyingKey.from_public_key_recovery( + sig, message, SECP256k1, hashlib.sha256 + ) signaling_byte = signature[64] key = keys[int(signaling_byte)] invoice.payee = key.to_string("compressed").hex() diff --git a/lnbits/commands.py b/lnbits/commands.py index 8236766e..2be04d12 100644 --- a/lnbits/commands.py +++ b/lnbits/commands.py @@ -7,7 +7,12 @@ import os from sqlalchemy.exc import OperationalError # type: ignore from .core import db as core_db, migrations as core_migrations -from .helpers import get_valid_extensions, get_css_vendored, get_js_vendored, url_for_vendored +from .helpers import ( + get_valid_extensions, + get_css_vendored, + get_js_vendored, + url_for_vendored, +) from .settings import LNBITS_PATH @@ -48,41 +53,41 @@ def bundle_vendored(): async def migrate_databases(): """Creates the necessary databases if they don't exist already; or migrates them.""" - core_conn = await core_db.connect() - core_txn = await core_conn.begin() - - try: - rows = await (await core_conn.execute("SELECT * FROM dbversions")).fetchall() - except OperationalError: - # migration 3 wasn't ran - await core_migrations.m000_create_migrations_table(core_conn) - rows = await (await core_conn.execute("SELECT * FROM dbversions")).fetchall() - - current_versions = {row["db"]: row["version"] for row in rows} - matcher = re.compile(r"^m(\d\d\d)_") - - async def run_migration(db, migrations_module): - db_name = migrations_module.__name__.split(".")[-2] - for key, migrate in migrations_module.__dict__.items(): - match = match = matcher.match(key) - if match: - version = int(match.group(1)) - if version > current_versions.get(db_name, 0): - print(f"running migration {db_name}.{version}") - await migrate(db) - await core_conn.execute( - "INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)", (db_name, version) - ) - - await run_migration(core_conn, core_migrations) - - for ext in get_valid_extensions(): + async with core_db.connect() as conn: try: - ext_migrations = importlib.import_module(f"lnbits.extensions.{ext.code}.migrations") - ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db - await run_migration(ext_db, ext_migrations) - except ImportError: - raise ImportError(f"Please make sure that the extension `{ext.code}` has a migrations file.") + rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall() + except OperationalError: + # migration 3 wasn't ran + await core_migrations.m000_create_migrations_table(conn) + rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall() - await core_txn.commit() - await core_conn.close() + current_versions = {row["db"]: row["version"] for row in rows} + matcher = re.compile(r"^m(\d\d\d)_") + + async def run_migration(db, migrations_module): + db_name = migrations_module.__name__.split(".")[-2] + for key, migrate in migrations_module.__dict__.items(): + match = match = matcher.match(key) + if match: + version = int(match.group(1)) + if version > current_versions.get(db_name, 0): + print(f"running migration {db_name}.{version}") + await migrate(db) + await conn.execute( + "INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)", + (db_name, version), + ) + + await run_migration(conn, core_migrations) + + for ext in get_valid_extensions(): + try: + ext_migrations = importlib.import_module( + f"lnbits.extensions.{ext.code}.migrations" + ) + ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db + await run_migration(ext_db, ext_migrations) + except ImportError: + raise ImportError( + f"Please make sure that the extension `{ext.code}` has a migrations file." + ) diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py index a2ea1ddf..ca0959a8 100644 --- a/lnbits/core/__init__.py +++ b/lnbits/core/__init__.py @@ -4,7 +4,11 @@ from lnbits.db import Database db = Database("database") core_app: Blueprint = Blueprint( - "core", __name__, template_folder="templates", static_folder="static", static_url_path="/core/static" + "core", + __name__, + template_folder="templates", + static_folder="static", + static_url_path="/core/static", ) diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index b66ab9bf..87d4972a 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -1,9 +1,10 @@ import json import datetime from uuid import uuid4 -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Any from lnbits import bolt11 +from lnbits.db import Connection from lnbits.settings import DEFAULT_WALLET_NAME from . import db @@ -14,28 +15,36 @@ from .models import User, Wallet, Payment # -------- -async def create_account() -> User: +async def create_account(conn: Optional[Connection] = None) -> User: user_id = uuid4().hex - await db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,)) + await (conn or db).execute("INSERT INTO accounts (id) VALUES (?)", (user_id,)) - new_account = await get_account(user_id=user_id) + new_account = await get_account(user_id=user_id, conn=conn) assert new_account, "Newly created account couldn't be retrieved" return new_account -async def get_account(user_id: str) -> Optional[User]: - row = await db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,)) +async def get_account( + user_id: str, conn: Optional[Connection] = None +) -> Optional[User]: + row = await (conn or db).fetchone( + "SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,) + ) return User(**row) if row else None -async def get_user(user_id: str) -> Optional[User]: - user = await db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,)) +async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]: + user = await (conn or db).fetchone( + "SELECT id, email FROM accounts WHERE id = ?", (user_id,) + ) if user: - extensions = await db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,)) - wallets = await db.fetchall( + extensions = await (conn or db).fetchall( + "SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,) + ) + wallets = await (conn or db).fetchall( """ SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat FROM wallets @@ -45,14 +54,24 @@ async def get_user(user_id: str) -> Optional[User]: ) return ( - User(**{**user, **{"extensions": [e[0] for e in extensions], "wallets": [Wallet(**w) for w in wallets]}}) + User( + **{ + **user, + **{ + "extensions": [e[0] for e in extensions], + "wallets": [Wallet(**w) for w in wallets], + }, + } + ) if user else None ) -async def update_user_extension(*, user_id: str, extension: str, active: int) -> None: - await db.execute( +async def update_user_extension( + *, user_id: str, extension: str, active: int, conn: Optional[Connection] = None +) -> None: + await (conn or db).execute( """ INSERT OR REPLACE INTO extensions (user, extension, active) VALUES (?, ?, ?) @@ -65,24 +84,37 @@ async def update_user_extension(*, user_id: str, extension: str, active: int) -> # ------- -async def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet: +async def create_wallet( + *, + user_id: str, + wallet_name: Optional[str] = None, + conn: Optional[Connection] = None, +) -> Wallet: wallet_id = uuid4().hex - await db.execute( + await (conn or db).execute( """ INSERT INTO wallets (id, name, user, adminkey, inkey) VALUES (?, ?, ?, ?, ?) """, - (wallet_id, wallet_name or DEFAULT_WALLET_NAME, user_id, uuid4().hex, uuid4().hex), + ( + wallet_id, + wallet_name or DEFAULT_WALLET_NAME, + user_id, + uuid4().hex, + uuid4().hex, + ), ) - new_wallet = await get_wallet(wallet_id=wallet_id) + new_wallet = await get_wallet(wallet_id=wallet_id, conn=conn) assert new_wallet, "Newly created wallet couldn't be retrieved" return new_wallet -async def delete_wallet(*, user_id: str, wallet_id: str) -> None: - await db.execute( +async def delete_wallet( + *, user_id: str, wallet_id: str, conn: Optional[Connection] = None +) -> None: + await (conn or db).execute( """ UPDATE wallets AS w SET @@ -95,8 +127,10 @@ async def delete_wallet(*, user_id: str, wallet_id: str) -> None: ) -async def get_wallet(wallet_id: str) -> Optional[Wallet]: - row = await db.fetchone( +async def get_wallet( + wallet_id: str, conn: Optional[Connection] = None +) -> Optional[Wallet]: + row = await (conn or db).fetchone( """ SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat FROM wallets @@ -108,8 +142,10 @@ async def get_wallet(wallet_id: str) -> Optional[Wallet]: return Wallet(**row) if row else None -async def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]: - row = await db.fetchone( +async def get_wallet_for_key( + key: str, key_type: str = "invoice", conn: Optional[Connection] = None +) -> Optional[Wallet]: + row = await (conn or db).fetchone( """ SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat FROM wallets @@ -131,21 +167,26 @@ async def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wa # --------------- -async def get_standalone_payment(checking_id: str) -> Optional[Payment]: - row = await db.fetchone( +async def get_standalone_payment( + checking_id_or_hash: str, conn: Optional[Connection] = None +) -> Optional[Payment]: + row = await (conn or db).fetchone( """ SELECT * FROM apipayments - WHERE checking_id = ? + WHERE checking_id = ? OR hash = ? + LIMIT 1 """, - (checking_id,), + (checking_id_or_hash, checking_id_or_hash), ) return Payment.from_row(row) if row else None -async def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]: - row = await db.fetchone( +async def get_wallet_payment( + wallet_id: str, payment_hash: str, conn: Optional[Connection] = None +) -> Optional[Payment]: + row = await (conn or db).fetchone( """ SELECT * FROM apipayments @@ -157,61 +198,75 @@ async def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Paym return Payment.from_row(row) if row else None -async def get_wallet_payments( - wallet_id: str, +async def get_payments( *, + wallet_id: Optional[str] = None, complete: bool = False, pending: bool = False, outgoing: bool = False, incoming: bool = False, + since: Optional[int] = None, exclude_uncheckable: bool = False, + conn: Optional[Connection] = None, ) -> List[Payment]: """ Filters payments to be returned by complete | pending | outgoing | incoming. """ - clause = "" - if complete and pending: - clause += "" - elif complete: - clause += "AND ((amount > 0 AND pending = 0) OR amount < 0)" - elif pending: - clause += "AND pending = 1" - else: - raise TypeError("at least one of [complete, pending] must be True.") + args: List[Any] = [] + clause: List[str] = [] - clause += " " + if since != None: + clause.append("time > ?") + args.append(since) + + if wallet_id: + clause.append("wallet = ?") + args.append(wallet_id) + + if complete and pending: + pass + elif complete: + clause.append("((amount > 0 AND pending = 0) OR amount < 0)") + elif pending: + clause.append("pending = 1") + else: + pass if outgoing and incoming: - clause += "" + pass elif outgoing: - clause += "AND amount < 0" + clause.append("amount < 0") elif incoming: - clause += "AND amount > 0" + clause.append("amount > 0") else: - raise TypeError("at least one of [outgoing, incoming] must be True.") - - clause += " " + pass if exclude_uncheckable: # checkable means it has a checking_id that isn't internal - clause += "AND checking_id NOT LIKE 'temp_%' " - clause += "AND checking_id NOT LIKE 'internal_%' " + clause.append("checking_id NOT LIKE 'temp_%'") + clause.append("checking_id NOT LIKE 'internal_%'") - rows = await db.fetchall( + where = "" + if clause: + where = f"WHERE {' AND '.join(clause)}" + + rows = await (conn or db).fetchall( f""" SELECT * FROM apipayments - WHERE wallet = ? {clause} + {where} ORDER BY time DESC """, - (wallet_id,), + tuple(args), ) return [Payment.from_row(row) for row in rows] -async def delete_expired_invoices() -> None: - rows = await db.fetchall( +async def delete_expired_invoices( + conn: Optional[Connection] = None, +) -> None: + rows = await (conn or db).fetchall( """ SELECT bolt11 FROM apipayments @@ -228,7 +283,7 @@ async def delete_expired_invoices() -> None: if expiration_date > datetime.datetime.utcnow(): continue - await db.execute( + await (conn or db).execute( """ DELETE FROM apipayments WHERE pending = 1 AND hash = ? @@ -254,8 +309,9 @@ async def create_payment( pending: bool = True, extra: Optional[Dict] = None, webhook: Optional[str] = None, + conn: Optional[Connection] = None, ) -> Payment: - await db.execute( + await (conn or db).execute( """ INSERT INTO apipayments (wallet, checking_id, bolt11, hash, preimage, @@ -272,29 +328,47 @@ async def create_payment( int(pending), memo, fee, - json.dumps(extra) if extra and extra != {} and type(extra) is dict else None, + json.dumps(extra) + if extra and extra != {} and type(extra) is dict + else None, webhook, ), ) - new_payment = await get_wallet_payment(wallet_id, payment_hash) + new_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) assert new_payment, "Newly created payment couldn't be retrieved" return new_payment -async def update_payment_status(checking_id: str, pending: bool) -> None: - await db.execute( - "UPDATE apipayments SET pending = ? WHERE checking_id = ?", (int(pending), checking_id,), +async def update_payment_status( + checking_id: str, + pending: bool, + conn: Optional[Connection] = None, +) -> None: + await (conn or db).execute( + "UPDATE apipayments SET pending = ? WHERE checking_id = ?", + ( + int(pending), + checking_id, + ), ) -async def delete_payment(checking_id: str) -> None: - await db.execute("DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)) +async def delete_payment( + checking_id: str, + conn: Optional[Connection] = None, +) -> None: + await (conn or db).execute( + "DELETE FROM apipayments WHERE checking_id = ?", (checking_id,) + ) -async def check_internal(payment_hash: str) -> Optional[str]: - row = await db.fetchone( +async def check_internal( + payment_hash: str, + conn: Optional[Connection] = None, +) -> Optional[str]: + row = await (conn or db).fetchone( """ SELECT checking_id FROM apipayments WHERE hash = ? AND pending AND amount > 0 diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index d0496322..0f14d9df 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -111,7 +111,12 @@ async def m002_add_fields_to_apipayments(db): UPDATE apipayments SET extra = ?, memo = ? WHERE checking_id = ? AND memo = ? """, - (json.dumps({"tag": ext}), new, row["checking_id"], row["memo"]), + ( + json.dumps({"tag": ext}), + new, + row["checking_id"], + row["memo"], + ), ) break except OperationalError: @@ -130,3 +135,29 @@ async def m003_add_invoice_webhook(db): await db.execute("ALTER TABLE apipayments ADD COLUMN webhook TEXT") await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT") + + +async def m004_ensure_fees_are_always_negative(db): + """ + Use abs() so wallet backends don't have to care about the sign of the fees. + """ + + await db.execute("DROP VIEW balances") + + await db.execute( + """ + CREATE VIEW IF NOT EXISTS balances AS + SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM ( + SELECT wallet, SUM(amount) AS s -- incoming + FROM apipayments + WHERE amount > 0 AND pending = 0 -- don't sum pending + GROUP BY wallet + UNION ALL + SELECT wallet, SUM(amount - abs(fee)) AS s -- outgoing, sum fees + FROM apipayments + WHERE amount < 0 -- do sum pending + GROUP BY wallet + ) + GROUP BY wallet; + """ + ) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index a79d73f4..d0b648c1 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -40,7 +40,11 @@ class Wallet(NamedTuple): hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest() linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256") - return SigningKey.from_string(linking_key, curve=SECP256k1, hashfunc=hashlib.sha256,) + return SigningKey.from_string( + linking_key, + curve=SECP256k1, + hashfunc=hashlib.sha256, + ) async def get_payment(self, payment_hash: str) -> Optional["Payment"]: from .crud import get_wallet_payment @@ -54,12 +58,12 @@ class Wallet(NamedTuple): pending: bool = False, outgoing: bool = True, incoming: bool = True, - exclude_uncheckable: bool = False + exclude_uncheckable: bool = False, ) -> List["Payment"]: - from .crud import get_wallet_payments + from .crud import get_payments - return await get_wallet_payments( - self.id, + return await get_payments( + wallet_id=self.id, complete=complete, pending=pending, outgoing=outgoing, @@ -123,7 +127,9 @@ class Payment(NamedTuple): @property def is_uncheckable(self) -> bool: - return self.checking_id.startswith("temp_") or self.checking_id.startswith("internal_") + return self.checking_id.startswith("temp_") or self.checking_id.startswith( + "internal_" + ) async def set_pending(self, pending: bool) -> None: from .crud import update_payment_status @@ -135,11 +141,18 @@ class Payment(NamedTuple): return if self.is_out: - pending = WALLET.get_payment_status(self.checking_id) + status = await WALLET.get_payment_status(self.checking_id) else: - pending = WALLET.get_invoice_status(self.checking_id) + status = await WALLET.get_invoice_status(self.checking_id) - await self.set_pending(pending.pending) + if self.is_out and status.failed: + print(f" - deleting outgoing failed payment {self.checking_id}: {status}") + await self.delete() + elif not status.pending: + print( + f" - marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}" + ) + await self.set_pending(status.pending) async def delete(self) -> None: from .crud import delete_payment diff --git a/lnbits/core/services.py b/lnbits/core/services.py index a5f7d996..28203c58 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -13,12 +13,28 @@ except ImportError: # pragma: nocover from typing_extensions import TypedDict from lnbits import bolt11 +from lnbits.db import Connection from lnbits.helpers import urlsafe_short_hash from lnbits.settings import WALLET from lnbits.wallets.base import PaymentStatus, PaymentResponse from . import db -from .crud import get_wallet, create_payment, delete_payment, check_internal, update_payment_status, get_wallet_payment +from .crud import ( + get_wallet, + create_payment, + delete_payment, + check_internal, + update_payment_status, + get_wallet_payment, +) + + +class PaymentFailure(Exception): + pass + + +class InvoiceFailure(Exception): + pass async def create_invoice( @@ -29,16 +45,16 @@ async def create_invoice( description_hash: Optional[bytes] = None, extra: Optional[Dict] = None, webhook: Optional[str] = None, + conn: Optional[Connection] = None, ) -> Tuple[str, str]: - await db.begin() invoice_memo = None if description_hash else memo storeable_memo = memo - ok, checking_id, payment_request, error_message = WALLET.create_invoice( + ok, checking_id, payment_request, error_message = await WALLET.create_invoice( amount=amount, memo=invoice_memo, description_hash=description_hash ) if not ok: - raise Exception(error_message or "Unexpected backend error.") + raise InvoiceFailure(error_message or "Unexpected backend error.") invoice = bolt11.decode(payment_request) @@ -52,9 +68,9 @@ async def create_invoice( memo=storeable_memo, extra=extra, webhook=webhook, + conn=conn, ) - await db.commit() return invoice.payment_hash, payment_request @@ -65,99 +81,131 @@ async def pay_invoice( max_sat: Optional[int] = None, extra: Optional[Dict] = None, description: str = "", + conn: Optional[Connection] = None, ) -> str: - await db.begin() - temp_id = f"temp_{urlsafe_short_hash()}" - internal_id = f"internal_{urlsafe_short_hash()}" + async with (db.reuse_conn(conn) if conn else db.connect()) as conn: + temp_id = f"temp_{urlsafe_short_hash()}" + internal_id = f"internal_{urlsafe_short_hash()}" - invoice = bolt11.decode(payment_request) - if invoice.amount_msat == 0: - raise ValueError("Amountless invoices not supported.") - if max_sat and invoice.amount_msat > max_sat * 1000: - raise ValueError("Amount in invoice is too high.") + invoice = bolt11.decode(payment_request) + if invoice.amount_msat == 0: + raise ValueError("Amountless invoices not supported.") + if max_sat and invoice.amount_msat > max_sat * 1000: + raise ValueError("Amount in invoice is too high.") - # put all parameters that don't change here - PaymentKwargs = TypedDict( - "PaymentKwargs", - { - "wallet_id": str, - "payment_request": str, - "payment_hash": str, - "amount": int, - "memo": str, - "extra": Optional[Dict], - }, - ) - payment_kwargs: PaymentKwargs = dict( - wallet_id=wallet_id, - payment_request=payment_request, - payment_hash=invoice.payment_hash, - amount=-invoice.amount_msat, - memo=description or invoice.description or "", - extra=extra, - ) + # put all parameters that don't change here + PaymentKwargs = TypedDict( + "PaymentKwargs", + { + "wallet_id": str, + "payment_request": str, + "payment_hash": str, + "amount": int, + "memo": str, + "extra": Optional[Dict], + }, + ) + payment_kwargs: PaymentKwargs = dict( + wallet_id=wallet_id, + payment_request=payment_request, + payment_hash=invoice.payment_hash, + amount=-invoice.amount_msat, + memo=description or invoice.description or "", + extra=extra, + ) - # check_internal() returns the checking_id of the invoice we're waiting for - internal_checking_id = await check_internal(invoice.payment_hash) - if internal_checking_id: - # create a new payment from this wallet - await create_payment(checking_id=internal_id, fee=0, pending=False, **payment_kwargs) - else: - # create a temporary payment here so we can check if - # the balance is enough in the next step - fee_reserve = max(1000, int(invoice.amount_msat * 0.01)) - await create_payment(checking_id=temp_id, fee=-fee_reserve, **payment_kwargs) - - # do the balance check - wallet = await get_wallet(wallet_id) - assert wallet - if wallet.balance_msat < 0: - await db.rollback() - raise PermissionError("Insufficient balance.") - else: - await db.commit() - await db.begin() - - if internal_checking_id: - # mark the invoice from the other side as not pending anymore - # so the other side only has access to his new money when we are sure - # the payer has enough to deduct from - await update_payment_status(checking_id=internal_checking_id, pending=False) - - # notify receiver asynchronously - from lnbits.tasks import internal_invoice_paid - - await internal_invoice_paid.send(internal_checking_id) - else: - # actually pay the external invoice - payment: PaymentResponse = WALLET.pay_invoice(payment_request) - if payment.ok and payment.checking_id: + # check_internal() returns the checking_id of the invoice we're waiting for + internal_checking_id = await check_internal(invoice.payment_hash, conn=conn) + if internal_checking_id: + # create a new payment from this wallet await create_payment( - checking_id=payment.checking_id, fee=payment.fee_msat, preimage=payment.preimage, **payment_kwargs, + checking_id=internal_id, + fee=0, + pending=False, + conn=conn, + **payment_kwargs, ) - await delete_payment(temp_id) else: - raise Exception(payment.error_message or "Failed to pay_invoice on backend.") + # create a temporary payment here so we can check if + # the balance is enough in the next step + fee_reserve = max(1000, int(invoice.amount_msat * 0.01)) + await create_payment( + checking_id=temp_id, + fee=-fee_reserve, + conn=conn, + **payment_kwargs, + ) - await db.commit() - return invoice.payment_hash + # do the balance check + wallet = await get_wallet(wallet_id, conn=conn) + assert wallet + if wallet.balance_msat < 0: + raise PermissionError("Insufficient balance.") + + if internal_checking_id: + # mark the invoice from the other side as not pending anymore + # so the other side only has access to his new money when we are sure + # the payer has enough to deduct from + await update_payment_status( + checking_id=internal_checking_id, + pending=False, + conn=conn, + ) + + # notify receiver asynchronously + from lnbits.tasks import internal_invoice_paid + + await internal_invoice_paid.send(internal_checking_id) + else: + # actually pay the external invoice + payment: PaymentResponse = await WALLET.pay_invoice(payment_request) + if payment.checking_id: + await create_payment( + checking_id=payment.checking_id, + fee=payment.fee_msat, + preimage=payment.preimage, + pending=payment.ok == None, + conn=conn, + **payment_kwargs, + ) + await delete_payment(temp_id, conn=conn) + else: + raise PaymentFailure( + payment.error_message + or "Payment failed, but backend didn't give us an error message." + ) + + return invoice.payment_hash -async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo: Optional[str] = None) -> None: +async def redeem_lnurl_withdraw( + wallet_id: str, + res: LnurlWithdrawResponse, + memo: Optional[str] = None, + conn: Optional[Connection] = None, +) -> None: _, payment_request = await create_invoice( wallet_id=wallet_id, amount=res.max_sats, memo=memo or res.default_description or "", extra={"tag": "lnurlwallet"}, + conn=conn, ) async with httpx.AsyncClient() as client: await client.get( - res.callback.base, params={**res.callback.query_params, **{"k1": res.k1, "pr": payment_request}}, + res.callback.base, + params={ + **res.callback.query_params, + **{"k1": res.k1, "pr": payment_request}, + }, ) -async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]: +async def perform_lnurlauth( + callback: str, + conn: Optional[Connection] = None, +) -> Optional[LnurlErrorResponse]: cb = urlparse(callback) k1 = unhexlify(parse_qs(cb.query)["k1"][0]) @@ -210,7 +258,11 @@ async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]: async with httpx.AsyncClient() as client: r = await client.get( callback, - params={"k1": k1.hex(), "key": key.verifying_key.to_string("compressed").hex(), "sig": sig.hex(),}, + params={ + "k1": k1.hex(), + "key": key.verifying_key.to_string("compressed").hex(), + "sig": sig.hex(), + }, ) try: resp = json.loads(r.text) @@ -219,12 +271,18 @@ async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]: return LnurlErrorResponse(reason=resp["reason"]) except (KeyError, json.decoder.JSONDecodeError): - return LnurlErrorResponse(reason=r.text[:200] + "..." if len(r.text) > 200 else r.text,) + return LnurlErrorResponse( + reason=r.text[:200] + "..." if len(r.text) > 200 else r.text, + ) -async def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus: - payment = await get_wallet_payment(wallet_id, payment_hash) +async def check_invoice_status( + wallet_id: str, + payment_hash: str, + conn: Optional[Connection] = None, +) -> PaymentStatus: + payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) if not payment: return PaymentStatus(None) - return WALLET.get_invoice_status(payment.checking_id) + return await WALLET.get_invoice_status(payment.checking_id) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 097a7819..bfed347b 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -584,18 +584,16 @@ new Vue({ LNbits.href.deleteWallet(walletId, user) }) }, - fetchPayments: function (checkPending) { - return LNbits.api - .getPayments(this.g.wallet, checkPending) - .then(response => { - this.payments = response.data - .map(obj => { - return LNbits.map.payment(obj) - }) - .sort((a, b) => { - return b.time - a.time - }) - }) + fetchPayments: function () { + return LNbits.api.getPayments(this.g.wallet).then(response => { + this.payments = response.data + .map(obj => { + return LNbits.map.payment(obj) + }) + .sort((a, b) => { + return b.time - a.time + }) + }) }, fetchBalance: function () { LNbits.api.getWallet(this.g.wallet).then(response => { @@ -606,16 +604,6 @@ new Vue({ ]) }) }, - checkPendingPayments: function () { - var dismissMsg = this.$q.notify({ - timeout: 0, - message: 'Checking pending transactions...' - }) - - this.fetchPayments(true).then(() => { - dismissMsg() - }) - }, exportCSV: function () { LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments) } @@ -628,7 +616,6 @@ new Vue({ created: function () { this.fetchBalance() this.fetchPayments() - setTimeout(this.checkPendingPayments(), 1200) }, mounted: function () { // show disclaimer diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index 8d1d5a90..763ef998 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -38,7 +38,11 @@ async def dispatch_webhook(payment: Payment): async with httpx.AsyncClient() as client: data = payment._asdict() try: - r = await client.post(payment.webhook, json=data, timeout=40,) + r = await client.post( + payment.webhook, + json=data, + timeout=40, + ) await mark_webhook_sent(payment, r.status_code) except (httpx.ConnectError, httpx.RequestError): await mark_webhook_sent(payment, -1) diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index e641a4ca..2fe30ec3 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -219,9 +219,6 @@
This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.
++ This extension allows you to connect a Bleskomat ATM to an lnbits + wallet. It will work with both the + open-source DIY Bleskomat ATM project + as well as the + commercial Bleskomat ATM. +
++ Since the Bleskomat ATMs are designed to be offline, a cryptographic + signing scheme is used to verify that the URL was generated by an + authorized device. When one of your customers inserts fiat money into + the device, a signed URL (lnurl-withdraw) is created and displayed as a + QR code. Your customer scans the QR code with their lnurl-supporting + mobile app, their mobile app communicates with the web API of lnbits to + verify the signature, the fiat currency amount is converted to sats, the + customer accepts the withdrawal, and finally lnbits will pay the + customer from your lnbits wallet. +
+[<paywall_object>, ...]
curl -X GET {{ request.url_root }}paywall/api/v1/paywalls -H
- "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{
+ g.user.wallets[0].inkey }}"
@@ -48,11 +48,11 @@
>
curl -X POST {{ request.url_root }}paywall/api/v1/paywalls -d
- '{"url": <string>, "memo": <string>, "description":
- <string>, "amount": <integer>, "remembers":
- <boolean>}' -H "Content-type: application/json" -H "X-Api-Key:
- {{ g.user.wallets[0].adminkey }}"
+ >curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url":
+ <string>, "memo": <string>, "description": <string>,
+ "amount": <integer>, "remembers": <boolean>}' -H
+ "Content-type: application/json" -H "X-Api-Key: {{
+ g.user.wallets[0].adminkey }}"
@@ -81,7 +81,7 @@
curl -X POST {{ request.url_root
- }}paywall/api/v1/paywalls/<paywall_id>/invoice -d '{"amount":
+ }}api/v1/paywalls/<paywall_id>/invoice -d '{"amount":
<integer>}' -H "Content-type: application/json"
@@ -112,7 +112,7 @@
curl -X POST {{ request.url_root
- }}paywall/api/v1/paywalls/<paywall_id>/check_invoice -d
+ }}api/v1/paywalls/<paywall_id>/check_invoice -d
'{"payment_hash": <string>}' -H "Content-type: application/json"
@@ -138,7 +138,7 @@
curl -X DELETE {{ request.url_root
- }}paywall/api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{
+ }}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
diff --git a/lnbits/extensions/paywall/views.py b/lnbits/extensions/paywall/views.py
index 7373d5c4..0dcbad2f 100644
--- a/lnbits/extensions/paywall/views.py
+++ b/lnbits/extensions/paywall/views.py
@@ -16,5 +16,7 @@ async def index():
@paywall_ext.route("/
+4. get Cloudflare API TOKEN
+
+
+5. Open the lnbits subdomains extension and register your domain with lnbits
+6. Click on the button in the table to open the public form that was generated for your domain
+
+- Extension also supports webhooks so you can get notified when someone buys a new domain
+
+
+## API Endpoints
+
+- **Domains**
+ - GET /api/v1/domains
+ - POST /api/v1/domains
+ - PUT /api/v1/domains/
+ Charge people for using your subdomain name...
+ Are you the owner of cool-domain.com and want to sell
+ cool-subdomain.cool-domain.com
+
+
+ Created by, Kris
+
+ Cost per day: {{ domain_cost }} sats
+ {% raw %} Total cost: {{amountSats}} sats {% endraw %}
+
[<tpos_object>, ...]
curl -X GET {{ request.url_root }}tpos/api/v1/tposs -H "X-Api-Key:
+ >curl -X GET {{ request.url_root }}api/v1/tposs -H "X-Api-Key:
<invoice_key>"
@@ -42,7 +42,7 @@
>
curl -X POST {{ request.url_root }}tpos/api/v1/tposs -d '{"name":
+ >curl -X POST {{ request.url_root }}api/v1/tposs -d '{"name":
<string>, "currency": <string>}' -H "Content-type:
application/json" -H "X-Api-Key: <admin_key>"
@@ -69,8 +69,8 @@
curl -X DELETE {{ request.url_root
- }}tpos/api/v1/tposs/<tpos_id> -H "X-Api-Key: <admin_key>"
+ >curl -X DELETE {{ request.url_root }}api/v1/tposs/<tpos_id> -H
+ "X-Api-Key: <admin_key>"
diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py
index 22980fce..1f0802c7 100644
--- a/lnbits/extensions/tpos/views_api.py
+++ b/lnbits/extensions/tpos/views_api.py
@@ -16,7 +16,10 @@ async def api_tposs():
if "all_wallets" in request.args:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
- return jsonify([tpos._asdict() for tpos in await get_tposs(wallet_ids)]), HTTPStatus.OK
+ return (
+ jsonify([tpos._asdict() for tpos in await get_tposs(wallet_ids)]),
+ HTTPStatus.OK,
+ )
@tpos_ext.route("/api/v1/tposs", methods=["POST"])
@@ -49,7 +52,9 @@ async def api_tpos_delete(tpos_id):
@tpos_ext.route("/api/v1/tposs/JSON list of users
curl -X GET {{ request.url_root }}usermanager/api/v1/users -H
- "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.url_root }}api/v1/users -H "X-Api-Key: {{
+ g.user.wallets[0].inkey }}"
@@ -64,9 +64,8 @@
JSON wallet data
curl -X GET {{ request.url_root
- }}usermanager/api/v1/wallets/<user_id> -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.url_root }}api/v1/wallets/<user_id> -H
+ "X-Api-Key: {{ g.user.wallets[0].inkey }}"
@@ -87,9 +86,8 @@
JSON a wallets transactions
curl -X GET {{ request.url_root
- }}usermanager/api/v1/wallets<wallet_id> -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.url_root }}api/v1/wallets<wallet_id> -H
+ "X-Api-Key: {{ g.user.wallets[0].inkey }}"
@@ -128,10 +126,10 @@
>
curl -X POST {{ request.url_root }}usermanager/api/v1/users -d
- '{"admin_id": "{{ g.user.id }}", "wallet_name": <string>,
- "user_name": <string>}' -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}" -H "Content-type: application/json"
+ >curl -X POST {{ request.url_root }}api/v1/users -d '{"admin_id": "{{
+ g.user.id }}", "wallet_name": <string>, "user_name":
+ <string>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
+ "Content-type: application/json"
@@ -165,10 +163,10 @@
>
curl -X POST {{ request.url_root }}usermanager/api/v1/wallets -d
- '{"user_id": <string>, "wallet_name": <string>,
- "admin_id": "{{ g.user.id }}"}' -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}" -H "Content-type: application/json"
+ >curl -X POST {{ request.url_root }}api/v1/wallets -d '{"user_id":
+ <string>, "wallet_name": <string>, "admin_id": "{{
+ g.user.id }}"}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
+ "Content-type: application/json"
@@ -189,9 +187,8 @@
{"X-Api-Key": <string>}
curl -X DELETE {{ request.url_root
- }}usermanager/api/v1/users/<user_id> -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ >curl -X DELETE {{ request.url_root }}api/v1/users/<user_id> -H
+ "X-Api-Key: {{ g.user.wallets[0].inkey }}"
@@ -207,9 +204,8 @@
{"X-Api-Key": <string>}
curl -X DELETE {{ request.url_root
- }}usermanager/api/v1/wallets/<wallet_id> -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ >curl -X DELETE {{ request.url_root }}api/v1/wallets/<wallet_id>
+ -H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
@@ -230,8 +226,8 @@
{"X-Api-Key": <string>}
curl -X POST {{ request.url_root }}usermanager/api/v1/extensions -d
- '{"userid": <string>, "extension": <string>, "active":
+ >curl -X POST {{ request.url_root }}api/v1/extensions -d '{"userid":
+ <string>, "extension": <string>, "active":
<integer>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
"Content-type: application/json"
diff --git a/lnbits/extensions/usermanager/views_api.py b/lnbits/extensions/usermanager/views_api.py
index 557aa8b9..bd64c070 100644
--- a/lnbits/extensions/usermanager/views_api.py
+++ b/lnbits/extensions/usermanager/views_api.py
@@ -26,7 +26,10 @@ from lnbits.core import update_user_extension
@api_check_wallet_key(key_type="invoice")
async def api_usermanager_users():
user_id = g.wallet.user
- return jsonify([user._asdict() for user in await get_usermanager_users(user_id)]), HTTPStatus.OK
+ return (
+ jsonify([user._asdict() for user in await get_usermanager_users(user_id)]),
+ HTTPStatus.OK,
+ )
@usermanager_ext.route("/api/v1/users", methods=["POST"])
@@ -39,7 +42,9 @@ async def api_usermanager_users():
}
)
async def api_usermanager_users_create():
- user = await create_usermanager_user(g.data["user_name"], g.data["wallet_name"], g.data["admin_id"])
+ user = await create_usermanager_user(
+ g.data["user_name"], g.data["wallet_name"], g.data["admin_id"]
+ )
return jsonify(user._asdict()), HTTPStatus.CREATED
@@ -69,7 +74,9 @@ async def api_usermanager_activate_extension():
user = await get_user(g.data["userid"])
if not user:
return jsonify({"message": "no such user"}), HTTPStatus.NOT_FOUND
- update_user_extension(user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"])
+ update_user_extension(
+ user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"]
+ )
return jsonify({"extension": "updated"}), HTTPStatus.CREATED
@@ -80,7 +87,12 @@ async def api_usermanager_activate_extension():
@api_check_wallet_key(key_type="invoice")
async def api_usermanager_wallets():
user_id = g.wallet.user
- return jsonify([wallet._asdict() for wallet in await get_usermanager_wallets(user_id)]), HTTPStatus.OK
+ return (
+ jsonify(
+ [wallet._asdict() for wallet in await get_usermanager_wallets(user_id)]
+ ),
+ HTTPStatus.OK,
+ )
@usermanager_ext.route("/api/v1/wallets", methods=["POST"])
@@ -93,7 +105,9 @@ async def api_usermanager_wallets():
}
)
async def api_usermanager_wallets_create():
- user = await create_usermanager_wallet(g.data["user_id"], g.data["wallet_name"], g.data["admin_id"])
+ user = await create_usermanager_wallet(
+ g.data["user_id"], g.data["wallet_name"], g.data["admin_id"]
+ )
return jsonify(user._asdict()), HTTPStatus.CREATED
diff --git a/lnbits/extensions/withdraw/__init__.py b/lnbits/extensions/withdraw/__init__.py
index 7afbf23c..69e45e4d 100644
--- a/lnbits/extensions/withdraw/__init__.py
+++ b/lnbits/extensions/withdraw/__init__.py
@@ -4,7 +4,9 @@ from lnbits.db import Database
db = Database("ext_withdraw")
-withdraw_ext: Blueprint = Blueprint("withdraw", __name__, static_folder="static", template_folder="templates")
+withdraw_ext: Blueprint = Blueprint(
+ "withdraw", __name__, static_folder="static", template_folder="templates"
+)
from .views_api import * # noqa
diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py
index 78fd7f56..dcc72af6 100644
--- a/lnbits/extensions/withdraw/crud.py
+++ b/lnbits/extensions/withdraw/crud.py
@@ -3,7 +3,7 @@ from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
-from .models import WithdrawLink
+from .models import WithdrawLink, HashCheck
async def create_withdraw_link(
@@ -58,6 +58,9 @@ async def create_withdraw_link(
async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]:
row = await db.fetchone("SELECT * FROM withdraw_link WHERE id = ?", (link_id,))
+ if not row:
+ return None
+
link = []
for item in row:
link.append(item)
@@ -66,7 +69,12 @@ async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]:
async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]:
- row = await db.fetchone("SELECT * FROM withdraw_link WHERE unique_hash = ?", (unique_hash,))
+ row = await db.fetchone(
+ "SELECT * FROM withdraw_link WHERE unique_hash = ?", (unique_hash,)
+ )
+ if not row:
+ return None
+
link = []
for item in row:
link.append(item)
@@ -79,14 +87,18 @@ async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[Withdraw
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
- rows = await db.fetchall(f"SELECT * FROM withdraw_link WHERE wallet IN ({q})", (*wallet_ids,))
+ rows = await db.fetchall(
+ f"SELECT * FROM withdraw_link WHERE wallet IN ({q})", (*wallet_ids,)
+ )
return [WithdrawLink.from_row(row) for row in rows]
async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
- await db.execute(f"UPDATE withdraw_link SET {q} WHERE id = ?", (*kwargs.values(), link_id))
+ await db.execute(
+ f"UPDATE withdraw_link SET {q} WHERE id = ?", (*kwargs.values(), link_id)
+ )
row = await db.fetchone("SELECT * FROM withdraw_link WHERE id = ?", (link_id,))
return WithdrawLink.from_row(row) if row else None
@@ -98,3 +110,40 @@ async def delete_withdraw_link(link_id: str) -> None:
def chunks(lst, n):
for i in range(0, len(lst), n):
yield lst[i : i + n]
+
+
+async def create_hash_check(
+ the_hash: str,
+ lnurl_id: str,
+) -> HashCheck:
+ await db.execute(
+ """
+ INSERT INTO hash_check (
+ id,
+ lnurl_id
+ )
+ VALUES (?, ?)
+ """,
+ (
+ the_hash,
+ lnurl_id,
+ ),
+ )
+ hashCheck = await get_hash_check(the_hash, lnurl_id)
+ return hashCheck
+
+
+async def get_hash_check(the_hash: str, lnurl_id: str) -> Optional[HashCheck]:
+ rowid = await db.fetchone("SELECT * FROM hash_check WHERE id = ?", (the_hash,))
+ rowlnurl = await db.fetchone(
+ "SELECT * FROM hash_check WHERE lnurl_id = ?", (lnurl_id,)
+ )
+ if not rowlnurl:
+ await create_hash_check(the_hash, lnurl_id)
+ return {"lnurl": True, "hash": False}
+ else:
+ if not rowid:
+ await create_hash_check(the_hash, lnurl_id)
+ return {"lnurl": True, "hash": False}
+ else:
+ return {"lnurl": True, "hash": True}
diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py
index 0a3cc629..e699e5b4 100644
--- a/lnbits/extensions/withdraw/lnurl.py
+++ b/lnbits/extensions/withdraw/lnurl.py
@@ -17,10 +17,16 @@ async def api_lnurl_response(unique_hash):
link = await get_withdraw_link_by_hash(unique_hash)
if not link:
- return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK
+ return (
+ jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}),
+ HTTPStatus.OK,
+ )
if link.is_spent:
- return jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), HTTPStatus.OK
+ return (
+ jsonify({"status": "ERROR", "reason": "Withdraw is spent."}),
+ HTTPStatus.OK,
+ )
return jsonify(link.lnurl_response.dict()), HTTPStatus.OK
@@ -33,10 +39,16 @@ async def api_lnurl_multi_response(unique_hash, id_unique_hash):
link = await get_withdraw_link_by_hash(unique_hash)
if not link:
- return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK
+ return (
+ jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}),
+ HTTPStatus.OK,
+ )
if link.is_spent:
- return jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), HTTPStatus.OK
+ return (
+ jsonify({"status": "ERROR", "reason": "Withdraw is spent."}),
+ HTTPStatus.OK,
+ )
useslist = link.usescsv.split(",")
found = False
@@ -45,7 +57,10 @@ async def api_lnurl_multi_response(unique_hash, id_unique_hash):
if id_unique_hash == shortuuid.uuid(name=tohash):
found = True
if not found:
- return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK
+ return (
+ jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}),
+ HTTPStatus.OK,
+ )
return jsonify(link.lnurl_response.dict()), HTTPStatus.OK
@@ -61,16 +76,27 @@ async def api_lnurl_callback(unique_hash):
now = int(datetime.now().timestamp())
if not link:
- return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK
+ return (
+ jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}),
+ HTTPStatus.OK,
+ )
if link.is_spent:
- return jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), HTTPStatus.OK
+ return (
+ jsonify({"status": "ERROR", "reason": "Withdraw is spent."}),
+ HTTPStatus.OK,
+ )
if link.k1 != k1:
return jsonify({"status": "ERROR", "reason": "Bad request."}), HTTPStatus.OK
if now < link.open_time:
- return jsonify({"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}), HTTPStatus.OK
+ return (
+ jsonify(
+ {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}
+ ),
+ HTTPStatus.OK,
+ )
try:
await pay_invoice(
@@ -85,12 +111,18 @@ async def api_lnurl_callback(unique_hash):
usecv = link.usescsv.split(",")
usescsv += "," + str(usecv[x])
usescsv = usescsv[1:]
- changes = {"open_time": link.wait_time + now, "used": link.used + 1, "usescsv": usescsv}
+ changes = {
+ "open_time": link.wait_time + now,
+ "used": link.used + 1,
+ "usescsv": usescsv,
+ }
await update_withdraw_link(link.id, **changes)
except ValueError as e:
- return jsonify({"status": "ERROR", "reason": str(e)}), HTTPStatus.OK
+ return jsonify({"status": "ERROR", "reason": str(e)})
except PermissionError:
- return jsonify({"status": "ERROR", "reason": "Withdraw link is empty."}), HTTPStatus.OK
+ return jsonify({"status": "ERROR", "reason": "Withdraw link is empty."})
+ except Exception as e:
+ return jsonify({"status": "ERROR", "reason": str(e)})
return jsonify({"status": "OK"}), HTTPStatus.OK
diff --git a/lnbits/extensions/withdraw/migrations.py b/lnbits/extensions/withdraw/migrations.py
index 4af24f8f..197a629c 100644
--- a/lnbits/extensions/withdraw/migrations.py
+++ b/lnbits/extensions/withdraw/migrations.py
@@ -47,7 +47,9 @@ async def m002_change_withdraw_table(db):
"""
)
await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON withdraw_link (wallet)")
- await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS unique_hash_idx ON withdraw_link (unique_hash)")
+ await db.execute(
+ "CREATE UNIQUE INDEX IF NOT EXISTS unique_hash_idx ON withdraw_link (unique_hash)"
+ )
for row in [list(row) for row in await db.fetchall("SELECT * FROM withdraw_links")]:
usescsv = ""
@@ -94,3 +96,17 @@ async def m002_change_withdraw_table(db):
),
)
await db.execute("DROP TABLE withdraw_links")
+
+
+async def m003_make_hash_check(db):
+ """
+ Creates a hash check table.
+ """
+ await db.execute(
+ """
+ CREATE TABLE IF NOT EXISTS hash_check (
+ id TEXT PRIMARY KEY,
+ lnurl_id TEXT
+ );
+ """
+ )
diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py
index 7e80a789..b7a98970 100644
--- a/lnbits/extensions/withdraw/models.py
+++ b/lnbits/extensions/withdraw/models.py
@@ -45,17 +45,32 @@ class WithdrawLink(NamedTuple):
_external=True,
)
else:
- url = url_for("withdraw.api_lnurl_response", unique_hash=self.unique_hash, _external=True)
+ url = url_for(
+ "withdraw.api_lnurl_response",
+ unique_hash=self.unique_hash,
+ _external=True,
+ )
return lnurl_encode(url)
@property
def lnurl_response(self) -> LnurlWithdrawResponse:
- url = url_for("withdraw.api_lnurl_callback", unique_hash=self.unique_hash, _external=True)
+ url = url_for(
+ "withdraw.api_lnurl_callback", unique_hash=self.unique_hash, _external=True
+ )
return LnurlWithdrawResponse(
callback=url,
k1=self.k1,
min_withdrawable=self.min_withdrawable * 1000,
max_withdrawable=self.max_withdrawable * 1000,
- default_description="LNbits voucher",
+ default_description=self.title,
)
+
+
+class HashCheck(NamedTuple):
+ id: str
+ lnurl_id: str
+
+ @classmethod
+ def from_row(cls, row: Row) -> "Hash":
+ return cls(**dict(row))
diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
index b87e3e3b..484464ba 100644
--- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
+++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
@@ -22,8 +22,8 @@
[<withdraw_link_object>, ...]
curl -X GET {{ request.url_root }}withdraw/api/v1/links -H
- "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{
+ g.user.wallets[0].inkey }}"
@@ -49,9 +49,8 @@
{"lnurl": <string>}
curl -X GET {{ request.url_root
- }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.url_root }}api/v1/links/<withdraw_id> -H
+ "X-Api-Key: {{ g.user.wallets[0].inkey }}"
@@ -79,8 +78,8 @@
{"lnurl": <string>}
curl -X POST {{ request.url_root }}withdraw/api/v1/links -d
- '{"title": <string>, "min_withdrawable": <integer>,
+ >curl -X POST {{ request.url_root }}api/v1/links -d '{"title":
+ <string>, "min_withdrawable": <integer>,
"max_withdrawable": <integer>, "uses": <integer>,
"wait_time": <integer>, "is_unique": <boolean>}' -H
"Content-type: application/json" -H "X-Api-Key: {{
@@ -115,9 +114,8 @@
{"lnurl": <string>}
Curl example
curl -X PUT {{ request.url_root
- }}withdraw/api/v1/links/<withdraw_id> -d '{"title":
- <string>, "min_withdrawable": <integer>,
+ >curl -X PUT {{ request.url_root }}api/v1/links/<withdraw_id> -d
+ '{"title": <string>, "min_withdrawable": <integer>,
"max_withdrawable": <integer>, "uses": <integer>,
"wait_time": <integer>, "is_unique": <boolean>}' -H
"Content-type: application/json" -H "X-Api-Key: {{
@@ -131,7 +129,6 @@
dense
expand-separator
label="Delete a withdraw link"
- class="q-pb-md"
>
@@ -145,9 +142,56 @@
Curl example
curl -X DELETE {{ request.url_root
- }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{
- g.user.wallets[0].adminkey }}"
+ >curl -X DELETE {{ request.url_root }}api/v1/links/<withdraw_id>
+ -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
+
+
+
+
+
+
+
+ GET
+ /withdraw/api/v1/links/<the_hash>/<lnurl_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 201 CREATED (application/json)
+
+ {"status": <bool>}
+ Curl example
+ curl -X GET {{ request.url_root
+ }}api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{
+ g.user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ GET
+ /withdraw/img/<lnurl_id>
+ Curl example
+ curl -X GET {{ request.url_root }}/withdraw/img/<lnurl_id>"
diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html
index 9e142a49..7442ca96 100644
--- a/lnbits/extensions/withdraw/templates/withdraw/index.html
+++ b/lnbits/extensions/withdraw/templates/withdraw/index.html
@@ -58,7 +58,20 @@
type="a"
:href="props.row.withdraw_url"
target="_blank"
- >
+ >
+ shareable link
+ embeddable image
+ > view LNURL
{{ col.value }}
diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py
index 574cb3ad..28f25756 100644
--- a/lnbits/extensions/withdraw/views.py
+++ b/lnbits/extensions/withdraw/views.py
@@ -1,6 +1,7 @@
from quart import g, abort, render_template
from http import HTTPStatus
-
+import pyqrcode
+from io import BytesIO
from lnbits.decorators import check_user_exists, validate_uuids
from . import withdraw_ext
@@ -16,19 +17,45 @@ async def index():
@withdraw_ext.route("/")
async def display(link_id):
- link = await get_withdraw_link(link_id, 0) or abort(HTTPStatus.NOT_FOUND, "Withdraw link does not exist.")
+ link = await get_withdraw_link(link_id, 0) or abort(
+ HTTPStatus.NOT_FOUND, "Withdraw link does not exist."
+ )
return await render_template("withdraw/display.html", link=link, unique=True)
+@withdraw_ext.route("/img/")
+async def img(link_id):
+ link = await get_withdraw_link(link_id, 0) or abort(
+ HTTPStatus.NOT_FOUND, "Withdraw link does not exist."
+ )
+ qr = pyqrcode.create(link.lnurl)
+ stream = BytesIO()
+ qr.svg(stream, scale=3)
+ return (
+ stream.getvalue(),
+ 200,
+ {
+ "Content-Type": "image/svg+xml",
+ "Cache-Control": "no-cache, no-store, must-revalidate",
+ "Pragma": "no-cache",
+ "Expires": "0",
+ },
+ )
+
+
@withdraw_ext.route("/print/")
async def print_qr(link_id):
- link = await get_withdraw_link(link_id) or abort(HTTPStatus.NOT_FOUND, "Withdraw link does not exist.")
+ link = await get_withdraw_link(link_id) or abort(
+ HTTPStatus.NOT_FOUND, "Withdraw link does not exist."
+ )
if link.uses == 0:
return await render_template("withdraw/print_qr.html", link=link, unique=False)
links = []
count = 0
for x in link.usescsv.split(","):
- linkk = await get_withdraw_link(link_id, count) or abort(HTTPStatus.NOT_FOUND, "Withdraw link does not exist.")
+ linkk = await get_withdraw_link(link_id, count) or abort(
+ HTTPStatus.NOT_FOUND, "Withdraw link does not exist."
+ )
links.append(str(linkk.lnurl))
count = count + 1
page_link = list(chunks(links, 2))
diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py
index cb8b7f0a..4979b932 100644
--- a/lnbits/extensions/withdraw/views_api.py
+++ b/lnbits/extensions/withdraw/views_api.py
@@ -12,6 +12,8 @@ from .crud import (
get_withdraw_links,
update_withdraw_link,
delete_withdraw_link,
+ create_hash_check,
+ get_hash_check,
)
@@ -37,7 +39,11 @@ async def api_links():
)
except LnurlInvalidUrl:
return (
- jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}),
+ jsonify(
+ {
+ "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
+ }
+ ),
HTTPStatus.UPGRADE_REQUIRED,
)
@@ -48,7 +54,10 @@ async def api_link_retrieve(link_id):
link = await get_withdraw_link(link_id, 0)
if not link:
- return jsonify({"message": "Withdraw link does not exist."}), HTTPStatus.NOT_FOUND
+ return (
+ jsonify({"message": "Withdraw link does not exist."}),
+ HTTPStatus.NOT_FOUND,
+ )
if link.wallet != g.wallet.id:
return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN
@@ -72,7 +81,11 @@ async def api_link_retrieve(link_id):
async def api_link_create_or_update(link_id=None):
if g.data["max_withdrawable"] < g.data["min_withdrawable"]:
return (
- jsonify({"message": "`max_withdrawable` needs to be at least `min_withdrawable`."}),
+ jsonify(
+ {
+ "message": "`max_withdrawable` needs to be at least `min_withdrawable`."
+ }
+ ),
HTTPStatus.BAD_REQUEST,
)
@@ -87,14 +100,22 @@ async def api_link_create_or_update(link_id=None):
if link_id:
link = await get_withdraw_link(link_id, 0)
if not link:
- return jsonify({"message": "Withdraw link does not exist."}), HTTPStatus.NOT_FOUND
+ return (
+ jsonify({"message": "Withdraw link does not exist."}),
+ HTTPStatus.NOT_FOUND,
+ )
if link.wallet != g.wallet.id:
return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN
link = await update_withdraw_link(link_id, **g.data, usescsv=usescsv, used=0)
else:
- link = await create_withdraw_link(wallet_id=g.wallet.id, **g.data, usescsv=usescsv)
+ link = await create_withdraw_link(
+ wallet_id=g.wallet.id, **g.data, usescsv=usescsv
+ )
- return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK if link_id else HTTPStatus.CREATED
+ return (
+ jsonify({**link._asdict(), **{"lnurl": link.lnurl}}),
+ HTTPStatus.OK if link_id else HTTPStatus.CREATED,
+ )
@withdraw_ext.route("/api/v1/links/", methods=["DELETE"])
@@ -103,7 +124,10 @@ async def api_link_delete(link_id):
link = await get_withdraw_link(link_id)
if not link:
- return jsonify({"message": "Withdraw link does not exist."}), HTTPStatus.NOT_FOUND
+ return (
+ jsonify({"message": "Withdraw link does not exist."}),
+ HTTPStatus.NOT_FOUND,
+ )
if link.wallet != g.wallet.id:
return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN
@@ -111,3 +135,10 @@ async def api_link_delete(link_id):
await delete_withdraw_link(link_id)
return "", HTTPStatus.NO_CONTENT
+
+
+@withdraw_ext.route("/api/v1/links//", methods=["GET"])
+@api_check_wallet_key("invoice")
+async def api_hash_retrieve(the_hash, lnurl_id):
+ hashCheck = await get_hash_check(the_hash, lnurl_id)
+ return jsonify(hashCheck), HTTPStatus.OK
diff --git a/lnbits/helpers.py b/lnbits/helpers.py
index ec7ec904..0370edbc 100644
--- a/lnbits/helpers.py
+++ b/lnbits/helpers.py
@@ -15,20 +15,27 @@ class Extension(NamedTuple):
short_description: Optional[str] = None
icon: Optional[str] = None
contributors: Optional[List[str]] = None
+ hidden: bool = False
class ExtensionManager:
def __init__(self):
self._disabled: List[str] = LNBITS_DISABLED_EXTENSIONS
- self._extension_folders: List[str] = [x[1] for x in os.walk(os.path.join(LNBITS_PATH, "extensions"))][0]
+ self._extension_folders: List[str] = [
+ x[1] for x in os.walk(os.path.join(LNBITS_PATH, "extensions"))
+ ][0]
@property
def extensions(self) -> List[Extension]:
output = []
- for extension in [ext for ext in self._extension_folders if ext not in self._disabled]:
+ for extension in [
+ ext for ext in self._extension_folders if ext not in self._disabled
+ ]:
try:
- with open(os.path.join(LNBITS_PATH, "extensions", extension, "config.json")) as json_file:
+ with open(
+ os.path.join(LNBITS_PATH, "extensions", extension, "config.json")
+ ) as json_file:
config = json.load(json_file)
is_valid = True
except Exception:
@@ -43,6 +50,7 @@ class ExtensionManager:
config.get("short_description"),
config.get("icon"),
config.get("contributors"),
+ config.get("hidden") or False,
)
)
@@ -50,7 +58,9 @@ class ExtensionManager:
def get_valid_extensions() -> List[Extension]:
- return [extension for extension in ExtensionManager().extensions if extension.is_valid]
+ return [
+ extension for extension in ExtensionManager().extensions if extension.is_valid
+ ]
def urlsafe_short_hash() -> str:
@@ -91,7 +101,9 @@ def get_css_vendored(prefer_minified: bool = False) -> List[str]:
def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
paths: List[str] = []
- for path in glob.glob(os.path.join(LNBITS_PATH, "static/vendor/**"), recursive=True):
+ for path in glob.glob(
+ os.path.join(LNBITS_PATH, "static/vendor/**"), recursive=True
+ ):
if path.endswith(".min" + ext):
# path is minified
unminified = path.replace(".min" + ext, ext)
diff --git a/lnbits/settings.py b/lnbits/settings.py
index 1ce46ec2..b42d06ec 100644
--- a/lnbits/settings.py
+++ b/lnbits/settings.py
@@ -10,7 +10,9 @@ env = Env()
env.read_env()
wallets_module = importlib.import_module("lnbits.wallets")
-wallet_class = getattr(wallets_module, env.str("LNBITS_BACKEND_WALLET_CLASS", default="VoidWallet"))
+wallet_class = getattr(
+ wallets_module, env.str("LNBITS_BACKEND_WALLET_CLASS", default="VoidWallet")
+)
ENV = env.str("QUART_ENV", default="production")
DEBUG = env.bool("QUART_DEBUG", default=False) or ENV == "development"
@@ -18,9 +20,15 @@ HOST = env.str("HOST", default="127.0.0.1")
PORT = env.int("PORT", default=5000)
LNBITS_PATH = path.dirname(path.realpath(__file__))
-LNBITS_DATA_FOLDER = env.str("LNBITS_DATA_FOLDER", default=path.join(LNBITS_PATH, "data"))
-LNBITS_ALLOWED_USERS: List[str] = env.list("LNBITS_ALLOWED_USERS", default=[], subcast=str)
-LNBITS_DISABLED_EXTENSIONS: List[str] = env.list("LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str)
+LNBITS_DATA_FOLDER = env.str(
+ "LNBITS_DATA_FOLDER", default=path.join(LNBITS_PATH, "data")
+)
+LNBITS_ALLOWED_USERS: List[str] = env.list(
+ "LNBITS_ALLOWED_USERS", default=[], subcast=str
+)
+LNBITS_DISABLED_EXTENSIONS: List[str] = env.list(
+ "LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str
+)
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")
WALLET = wallet_class()
diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js
index ed0583e5..122d676d 100644
--- a/lnbits/static/js/base.js
+++ b/lnbits/static/js/base.js
@@ -52,13 +52,8 @@ window.LNbits = {
getWallet: function (wallet) {
return this.request('get', '/api/v1/wallet', wallet.inkey)
},
- getPayments: function (wallet, checkPending) {
- var query_param = checkPending ? '?check_pending' : ''
- return this.request(
- 'get',
- ['/api/v1/payments', query_param].join(''),
- wallet.inkey
- )
+ getPayments: function (wallet) {
+ return this.request('get', '/api/v1/payments', wallet.inkey)
},
getPayment: function (wallet, paymentHash) {
return this.request(
@@ -93,7 +88,15 @@ window.LNbits = {
map: {
extension: function (data) {
var obj = _.object(
- ['code', 'isValid', 'name', 'shortDescription', 'icon'],
+ [
+ 'code',
+ 'isValid',
+ 'name',
+ 'shortDescription',
+ 'icon',
+ 'contributors',
+ 'hidden'
+ ],
data
)
obj.url = ['/', obj.code, '/'].join('')
@@ -309,6 +312,9 @@ window.windowMixin = {
.map(function (data) {
return window.LNbits.map.extension(data)
})
+ .filter(function (obj) {
+ return !obj.hidden
+ })
.map(function (obj) {
if (user) {
obj.isEnabled = user.extensions.indexOf(obj.code) !== -1
diff --git a/lnbits/tasks.py b/lnbits/tasks.py
index 3acb2a07..d8f26a75 100644
--- a/lnbits/tasks.py
+++ b/lnbits/tasks.py
@@ -1,10 +1,15 @@
+import time
import trio # type: ignore
from http import HTTPStatus
from typing import Optional, List, Callable
from quart_trio import QuartTrio
from lnbits.settings import WALLET
-from lnbits.core.crud import get_standalone_payment
+from lnbits.core.crud import (
+ get_payments,
+ get_standalone_payment,
+ delete_expired_invoices,
+)
main_app: Optional[QuartTrio] = None
@@ -54,16 +59,38 @@ async def webhook_handler():
internal_invoice_paid, internal_invoice_received = trio.open_memory_channel(0)
-async def internal_invoice_listener():
- async with trio.open_nursery() as nursery:
- async for checking_id in internal_invoice_received:
- nursery.start_soon(invoice_callback_dispatcher, checking_id)
+async def internal_invoice_listener(nursery):
+ async for checking_id in internal_invoice_received:
+ nursery.start_soon(invoice_callback_dispatcher, checking_id)
-async def invoice_listener():
- async with trio.open_nursery() as nursery:
- async for checking_id in WALLET.paid_invoices_stream():
- nursery.start_soon(invoice_callback_dispatcher, checking_id)
+async def invoice_listener(nursery):
+ async for checking_id in WALLET.paid_invoices_stream():
+ nursery.start_soon(invoice_callback_dispatcher, checking_id)
+
+
+async def check_pending_payments():
+ await delete_expired_invoices()
+
+ outgoing = True
+ incoming = True
+
+ while True:
+ for payment in await get_payments(
+ since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
+ complete=False,
+ pending=True,
+ outgoing=outgoing,
+ incoming=incoming,
+ exclude_uncheckable=True,
+ ):
+ await payment.check_pending()
+
+ # after the first check we will only check outgoing, not incoming
+ # that will be handled by the global invoice listeners, hopefully
+ incoming = False
+
+ await trio.sleep(60 * 30) # every 30 minutes
async def invoice_callback_dispatcher(checking_id: str):
diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html
index d81cf2a9..59016e99 100644
--- a/lnbits/templates/base.html
+++ b/lnbits/templates/base.html
@@ -14,6 +14,8 @@
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
+
+
{% block head_scripts %}{% endblock %}
diff --git a/lnbits/utils/__init__.py b/lnbits/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py
new file mode 100644
index 00000000..ef4d3306
--- /dev/null
+++ b/lnbits/utils/exchange_rates.py
@@ -0,0 +1,267 @@
+import trio # type: ignore
+import httpx
+from typing import Callable, NamedTuple
+
+currencies = {
+ "AED": "United Arab Emirates Dirham",
+ "AFN": "Afghan Afghani",
+ "ALL": "Albanian Lek",
+ "AMD": "Armenian Dram",
+ "ANG": "Netherlands Antillean Gulden",
+ "AOA": "Angolan Kwanza",
+ "ARS": "Argentine Peso",
+ "AUD": "Australian Dollar",
+ "AWG": "Aruban Florin",
+ "AZN": "Azerbaijani Manat",
+ "BAM": "Bosnia and Herzegovina Convertible Mark",
+ "BBD": "Barbadian Dollar",
+ "BDT": "Bangladeshi Taka",
+ "BGN": "Bulgarian Lev",
+ "BHD": "Bahraini Dinar",
+ "BIF": "Burundian Franc",
+ "BMD": "Bermudian Dollar",
+ "BND": "Brunei Dollar",
+ "BOB": "Bolivian Boliviano",
+ "BRL": "Brazilian Real",
+ "BSD": "Bahamian Dollar",
+ "BTN": "Bhutanese Ngultrum",
+ "BWP": "Botswana Pula",
+ "BYN": "Belarusian Ruble",
+ "BYR": "Belarusian Ruble",
+ "BZD": "Belize Dollar",
+ "CAD": "Canadian Dollar",
+ "CDF": "Congolese Franc",
+ "CHF": "Swiss Franc",
+ "CLF": "Unidad de Fomento",
+ "CLP": "Chilean Peso",
+ "CNH": "Chinese Renminbi Yuan Offshore",
+ "CNY": "Chinese Renminbi Yuan",
+ "COP": "Colombian Peso",
+ "CRC": "Costa Rican Colón",
+ "CUC": "Cuban Convertible Peso",
+ "CVE": "Cape Verdean Escudo",
+ "CZK": "Czech Koruna",
+ "DJF": "Djiboutian Franc",
+ "DKK": "Danish Krone",
+ "DOP": "Dominican Peso",
+ "DZD": "Algerian Dinar",
+ "EGP": "Egyptian Pound",
+ "ERN": "Eritrean Nakfa",
+ "ETB": "Ethiopian Birr",
+ "EUR": "Euro",
+ "FJD": "Fijian Dollar",
+ "FKP": "Falkland Pound",
+ "GBP": "British Pound",
+ "GEL": "Georgian Lari",
+ "GGP": "Guernsey Pound",
+ "GHS": "Ghanaian Cedi",
+ "GIP": "Gibraltar Pound",
+ "GMD": "Gambian Dalasi",
+ "GNF": "Guinean Franc",
+ "GTQ": "Guatemalan Quetzal",
+ "GYD": "Guyanese Dollar",
+ "HKD": "Hong Kong Dollar",
+ "HNL": "Honduran Lempira",
+ "HRK": "Croatian Kuna",
+ "HTG": "Haitian Gourde",
+ "HUF": "Hungarian Forint",
+ "IDR": "Indonesian Rupiah",
+ "ILS": "Israeli New Sheqel",
+ "IMP": "Isle of Man Pound",
+ "INR": "Indian Rupee",
+ "IQD": "Iraqi Dinar",
+ "ISK": "Icelandic Króna",
+ "JEP": "Jersey Pound",
+ "JMD": "Jamaican Dollar",
+ "JOD": "Jordanian Dinar",
+ "JPY": "Japanese Yen",
+ "KES": "Kenyan Shilling",
+ "KGS": "Kyrgyzstani Som",
+ "KHR": "Cambodian Riel",
+ "KMF": "Comorian Franc",
+ "KRW": "South Korean Won",
+ "KWD": "Kuwaiti Dinar",
+ "KYD": "Cayman Islands Dollar",
+ "KZT": "Kazakhstani Tenge",
+ "LAK": "Lao Kip",
+ "LBP": "Lebanese Pound",
+ "LKR": "Sri Lankan Rupee",
+ "LRD": "Liberian Dollar",
+ "LSL": "Lesotho Loti",
+ "LYD": "Libyan Dinar",
+ "MAD": "Moroccan Dirham",
+ "MDL": "Moldovan Leu",
+ "MGA": "Malagasy Ariary",
+ "MKD": "Macedonian Denar",
+ "MMK": "Myanmar Kyat",
+ "MNT": "Mongolian Tögrög",
+ "MOP": "Macanese Pataca",
+ "MRO": "Mauritanian Ouguiya",
+ "MUR": "Mauritian Rupee",
+ "MVR": "Maldivian Rufiyaa",
+ "MWK": "Malawian Kwacha",
+ "MXN": "Mexican Peso",
+ "MYR": "Malaysian Ringgit",
+ "MZN": "Mozambican Metical",
+ "NAD": "Namibian Dollar",
+ "NGN": "Nigerian Naira",
+ "NIO": "Nicaraguan Córdoba",
+ "NOK": "Norwegian Krone",
+ "NPR": "Nepalese Rupee",
+ "NZD": "New Zealand Dollar",
+ "OMR": "Omani Rial",
+ "PAB": "Panamanian Balboa",
+ "PEN": "Peruvian Sol",
+ "PGK": "Papua New Guinean Kina",
+ "PHP": "Philippine Peso",
+ "PKR": "Pakistani Rupee",
+ "PLN": "Polish Złoty",
+ "PYG": "Paraguayan Guaraní",
+ "QAR": "Qatari Riyal",
+ "RON": "Romanian Leu",
+ "RSD": "Serbian Dinar",
+ "RUB": "Russian Ruble",
+ "RWF": "Rwandan Franc",
+ "SAR": "Saudi Riyal",
+ "SBD": "Solomon Islands Dollar",
+ "SCR": "Seychellois Rupee",
+ "SEK": "Swedish Krona",
+ "SGD": "Singapore Dollar",
+ "SHP": "Saint Helenian Pound",
+ "SLL": "Sierra Leonean Leone",
+ "SOS": "Somali Shilling",
+ "SRD": "Surinamese Dollar",
+ "SSP": "South Sudanese Pound",
+ "STD": "São Tomé and Príncipe Dobra",
+ "SVC": "Salvadoran Colón",
+ "SZL": "Swazi Lilangeni",
+ "THB": "Thai Baht",
+ "TJS": "Tajikistani Somoni",
+ "TMT": "Turkmenistani Manat",
+ "TND": "Tunisian Dinar",
+ "TOP": "Tongan Paʻanga",
+ "TRY": "Turkish Lira",
+ "TTD": "Trinidad and Tobago Dollar",
+ "TWD": "New Taiwan Dollar",
+ "TZS": "Tanzanian Shilling",
+ "UAH": "Ukrainian Hryvnia",
+ "UGX": "Ugandan Shilling",
+ "USD": "US Dollar",
+ "UYU": "Uruguayan Peso",
+ "UZS": "Uzbekistan Som",
+ "VEF": "Venezuelan Bolívar",
+ "VES": "Venezuelan Bolívar Soberano",
+ "VND": "Vietnamese Đồng",
+ "VUV": "Vanuatu Vatu",
+ "WST": "Samoan Tala",
+ "XAF": "Central African Cfa Franc",
+ "XAG": "Silver (Troy Ounce)",
+ "XAU": "Gold (Troy Ounce)",
+ "XCD": "East Caribbean Dollar",
+ "XDR": "Special Drawing Rights",
+ "XOF": "West African Cfa Franc",
+ "XPD": "Palladium",
+ "XPF": "Cfp Franc",
+ "XPT": "Platinum",
+ "YER": "Yemeni Rial",
+ "ZAR": "South African Rand",
+ "ZMW": "Zambian Kwacha",
+ "ZWL": "Zimbabwean Dollar",
+}
+
+
+class Provider(NamedTuple):
+ name: str
+ domain: str
+ api_url: str
+ getter: Callable
+
+
+exchange_rate_providers = {
+ "bitfinex": Provider(
+ "Bitfinex",
+ "bitfinex.com",
+ "https://api.bitfinex.com/v1/pubticker/{from}{to}",
+ lambda data, replacements: data["last_price"],
+ ),
+ "bitstamp": Provider(
+ "Bitstamp",
+ "bitstamp.net",
+ "https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
+ lambda data, replacements: data["last"],
+ ),
+ "coinbase": Provider(
+ "Coinbase",
+ "coinbase.com",
+ "https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
+ lambda data, replacements: data["data"]["rates"][replacements["TO"]],
+ ),
+ "coinmate": Provider(
+ "CoinMate",
+ "coinmate.io",
+ "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
+ lambda data, replacements: data["data"]["last"],
+ ),
+ "kraken": Provider(
+ "Kraken",
+ "kraken.com",
+ "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
+ lambda data, replacements: data["result"]["XXBTZ" + replacements["TO"]]["c"][0],
+ ),
+}
+
+
+async def btc_price(currency: str) -> float:
+ replacements = {
+ "FROM": "BTC",
+ "from": "btc",
+ "TO": currency.upper(),
+ "to": currency.lower(),
+ }
+ rates = []
+ send_channel, receive_channel = trio.open_memory_channel(0)
+
+ async def controller(nursery):
+ failures = 0
+ while True:
+ rate = await receive_channel.receive()
+ if rate:
+ rates.append(rate)
+ else:
+ failures += 1
+ if len(rates) >= 2 or len(rates) == 1 and failures >= 2:
+ nursery.cancel_scope.cancel()
+ break
+ if failures == len(exchange_rate_providers):
+ nursery.cancel_scope.cancel()
+ break
+
+ async def fetch_price(key: str, provider: Provider):
+ try:
+ url = provider.api_url.format(**replacements)
+ async with httpx.AsyncClient() as client:
+ r = await client.get(url, timeout=0.5)
+ r.raise_for_status()
+ data = r.json()
+ rate = float(provider.getter(data, replacements))
+ await send_channel.send(rate)
+ except Exception:
+ await send_channel.send(None)
+
+ async with trio.open_nursery() as nursery:
+ nursery.start_soon(controller, nursery)
+ for key, provider in exchange_rate_providers.items():
+ nursery.start_soon(fetch_price, key, provider)
+
+ if not rates:
+ return 9999999999
+
+ return sum([rate for rate in rates]) / len(rates)
+
+
+async def get_fiat_rate_satoshis(currency: str) -> float:
+ return int(100_000_000 / (await btc_price(currency)))
+
+
+async def fiat_amount_as_satoshis(amount: float, currency: str) -> int:
+ return int(amount * (await get_fiat_rate_satoshis(currency)))
diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py
index d2486c73..973c1808 100644
--- a/lnbits/wallets/base.py
+++ b/lnbits/wallets/base.py
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
-from typing import NamedTuple, Optional, AsyncGenerator
+from typing import NamedTuple, Optional, AsyncGenerator, Coroutine
class StatusResponse(NamedTuple):
@@ -15,7 +15,8 @@ class InvoiceResponse(NamedTuple):
class PaymentResponse(NamedTuple):
- ok: bool
+ # when ok is None it means we don't know if this succeeded
+ ok: Optional[bool] = None
checking_id: Optional[str] = None # payment_hash, rcp_id
fee_msat: int = 0
preimage: Optional[str] = None
@@ -29,28 +30,49 @@ class PaymentStatus(NamedTuple):
def pending(self) -> bool:
return self.paid is not True
+ @property
+ def failed(self) -> bool:
+ return self.paid == False
+
+ def __str__(self) -> str:
+ if self.paid == True:
+ return "settled"
+ elif self.paid == False:
+ return "failed"
+ elif self.paid == None:
+ return "still pending"
+ else:
+ return "unknown (should never happen)"
+
class Wallet(ABC):
@abstractmethod
- def status(self) -> StatusResponse:
+ def status(self) -> Coroutine[None, None, StatusResponse]:
pass
@abstractmethod
def create_invoice(
- self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
- ) -> InvoiceResponse:
+ self,
+ amount: int,
+ memo: Optional[str] = None,
+ description_hash: Optional[bytes] = None,
+ ) -> Coroutine[None, None, InvoiceResponse]:
pass
@abstractmethod
- def pay_invoice(self, bolt11: str) -> PaymentResponse:
+ def pay_invoice(self, bolt11: str) -> Coroutine[None, None, PaymentResponse]:
pass
@abstractmethod
- def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ def get_invoice_status(
+ self, checking_id: str
+ ) -> Coroutine[None, None, PaymentStatus]:
pass
@abstractmethod
- def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ def get_payment_status(
+ self, checking_id: str
+ ) -> Coroutine[None, None, PaymentStatus]:
pass
@abstractmethod
diff --git a/lnbits/wallets/clightning.py b/lnbits/wallets/clightning.py
index 48d304bb..b9f24f57 100644
--- a/lnbits/wallets/clightning.py
+++ b/lnbits/wallets/clightning.py
@@ -10,13 +10,22 @@ import json
from os import getenv
from typing import Optional, AsyncGenerator
-from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
+from .base import (
+ StatusResponse,
+ InvoiceResponse,
+ PaymentResponse,
+ PaymentStatus,
+ Wallet,
+ Unsupported,
+)
class CLightningWallet(Wallet):
def __init__(self):
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.rpc = getenv("CLIGHTNING_RPC")
self.ln = LightningRpc(self.rpc)
@@ -40,7 +49,7 @@ class CLightningWallet(Wallet):
self.last_pay_index = inv["pay_index"]
break
- def status(self) -> StatusResponse:
+ async def status(self) -> StatusResponse:
try:
funds = self.ln.listfunds()
return StatusResponse(
@@ -51,8 +60,11 @@ class CLightningWallet(Wallet):
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
return StatusResponse(error_message, 0)
- def create_invoice(
- self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
+ async def create_invoice(
+ self,
+ amount: int,
+ memo: Optional[str] = None,
+ description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
label = "lbl{}".format(random.random())
msat = amount * 1000
@@ -72,7 +84,7 @@ class CLightningWallet(Wallet):
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
return InvoiceResponse(False, label, None, error_message)
- def pay_invoice(self, bolt11: str) -> PaymentResponse:
+ async def pay_invoice(self, bolt11: str) -> PaymentResponse:
try:
r = self.ln.pay(bolt11)
except RpcError as exc:
@@ -82,7 +94,7 @@ class CLightningWallet(Wallet):
preimage = r["payment_preimage"]
return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None)
- def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = self.ln.listinvoices(checking_id)
if not r["invoices"]:
return PaymentStatus(False)
@@ -90,7 +102,7 @@ class CLightningWallet(Wallet):
return PaymentStatus(r["invoices"][0]["status"] == "paid")
raise KeyError("supplied an invalid checking_id")
- def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ async def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = self.ln.call("listpays", {"payment_hash": checking_id})
if not r["pays"]:
return PaymentStatus(False)
diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py
index 30c62c60..786a4b03 100644
--- a/lnbits/wallets/lnbits.py
+++ b/lnbits/wallets/lnbits.py
@@ -3,7 +3,13 @@ import httpx
from os import getenv
from typing import Optional, Dict, AsyncGenerator
-from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
+from .base import (
+ StatusResponse,
+ InvoiceResponse,
+ PaymentResponse,
+ PaymentStatus,
+ Wallet,
+)
class LNbitsWallet(Wallet):
@@ -12,23 +18,34 @@ class LNbitsWallet(Wallet):
def __init__(self):
self.endpoint = getenv("LNBITS_ENDPOINT")
- key = getenv("LNBITS_KEY") or getenv("LNBITS_ADMIN_KEY") or getenv("LNBITS_INVOICE_KEY")
+ key = (
+ getenv("LNBITS_KEY")
+ or getenv("LNBITS_ADMIN_KEY")
+ or getenv("LNBITS_INVOICE_KEY")
+ )
self.key = {"X-Api-Key": key}
- def status(self) -> StatusResponse:
- r = httpx.get(url=f"{self.endpoint}/api/v1/wallet", headers=self.key)
+ async def status(self) -> StatusResponse:
+ async with httpx.AsyncClient() as client:
+ r = await client.get(url=f"{self.endpoint}/api/v1/wallet", headers=self.key)
+
try:
data = r.json()
except:
- return StatusResponse(f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0)
+ return StatusResponse(
+ f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0
+ )
if r.is_error:
return StatusResponse(data["message"], 0)
return StatusResponse(None, data["balance"])
- def create_invoice(
- self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
+ async def create_invoice(
+ self,
+ amount: int,
+ memo: Optional[str] = None,
+ description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
data: Dict = {"out": False, "amount": amount}
if description_hash:
@@ -36,12 +53,18 @@ class LNbitsWallet(Wallet):
else:
data["memo"] = memo or ""
- r = httpx.post(
- url=f"{self.endpoint}/api/v1/payments",
- headers=self.key,
- json=data,
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ url=f"{self.endpoint}/api/v1/payments",
+ headers=self.key,
+ json=data,
+ )
+ ok, checking_id, payment_request, error_message = (
+ not r.is_error,
+ None,
+ None,
+ None,
)
- ok, checking_id, payment_request, error_message = not r.is_error, None, None, None
if r.is_error:
error_message = r.json()["message"]
@@ -51,8 +74,13 @@ class LNbitsWallet(Wallet):
return InvoiceResponse(ok, checking_id, payment_request, error_message)
- def pay_invoice(self, bolt11: str) -> PaymentResponse:
- r = httpx.post(url=f"{self.endpoint}/api/v1/payments", headers=self.key, json={"out": True, "bolt11": bolt11})
+ async def pay_invoice(self, bolt11: str) -> PaymentResponse:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ url=f"{self.endpoint}/api/v1/payments",
+ headers=self.key,
+ json={"out": True, "bolt11": bolt11},
+ )
ok, checking_id, fee_msat, error_message = not r.is_error, None, 0, None
if r.is_error:
@@ -63,16 +91,22 @@ class LNbitsWallet(Wallet):
return PaymentResponse(ok, checking_id, fee_msat, error_message)
- def get_invoice_status(self, checking_id: str) -> PaymentStatus:
- r = httpx.get(url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key)
+ async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ async with httpx.AsyncClient() as client:
+ r = await client.get(
+ url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key
+ )
if r.is_error:
return PaymentStatus(None)
return PaymentStatus(r.json()["paid"])
- def get_payment_status(self, checking_id: str) -> PaymentStatus:
- r = httpx.get(url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key)
+ async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ async with httpx.AsyncClient() as client:
+ r = await client.get(
+ url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key
+ )
if r.is_error:
return PaymentStatus(None)
diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py
index d92b568f..25c9b283 100644
--- a/lnbits/wallets/lndgrpc.py
+++ b/lnbits/wallets/lndgrpc.py
@@ -15,7 +15,13 @@ import hashlib
from os import getenv
from typing import Optional, Dict, AsyncGenerator
-from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
+from .base import (
+ StatusResponse,
+ InvoiceResponse,
+ PaymentResponse,
+ PaymentStatus,
+ Wallet,
+)
def get_ssl_context(cert_path: str):
@@ -76,10 +82,14 @@ def stringify_checking_id(r_hash: bytes) -> str:
class LndWallet(Wallet):
def __init__(self):
if lndgrpc is None: # pragma: nocover
- raise ImportError("The `lndgrpc` 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`.")
+ 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
@@ -102,7 +112,7 @@ class LndWallet(Wallet):
macaroon_filepath=self.macaroon_path,
)
- def status(self) -> StatusResponse:
+ async def status(self) -> StatusResponse:
try:
resp = self.rpc._ln_stub.ChannelBalance(ln.ChannelBalanceRequest())
except Exception as exc:
@@ -110,8 +120,11 @@ class LndWallet(Wallet):
return StatusResponse(None, resp.balance * 1000)
- def create_invoice(
- self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
+ async def create_invoice(
+ self,
+ amount: int,
+ memo: Optional[str] = None,
+ description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
params: Dict = {"value": amount, "expiry": 600, "private": True}
@@ -131,7 +144,7 @@ class LndWallet(Wallet):
payment_request = str(resp.payment_request)
return InvoiceResponse(True, checking_id, payment_request, None)
- def pay_invoice(self, bolt11: str) -> PaymentResponse:
+ async def pay_invoice(self, bolt11: str) -> PaymentResponse:
resp = self.rpc.send_payment(payment_request=bolt11)
if resp.payment_error:
@@ -143,7 +156,7 @@ class LndWallet(Wallet):
preimage = resp.payment_preimage.hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
- def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r_hash = parse_checking_id(checking_id)
if len(r_hash) != 32:
@@ -159,7 +172,7 @@ class LndWallet(Wallet):
return PaymentStatus(None)
- def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ async def get_payment_status(self, checking_id: str) -> PaymentStatus:
return PaymentStatus(True)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py
index 4ee42aa7..b6746c6b 100644
--- a/lnbits/wallets/lndrest.py
+++ b/lnbits/wallets/lndrest.py
@@ -5,7 +5,13 @@ import base64
from os import getenv
from typing import Optional, Dict, AsyncGenerator
-from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
+from .base import (
+ StatusResponse,
+ InvoiceResponse,
+ PaymentResponse,
+ PaymentStatus,
+ Wallet,
+)
class LndRestWallet(Wallet):
@@ -14,7 +20,9 @@ class LndRestWallet(Wallet):
def __init__(self):
endpoint = getenv("LND_REST_ENDPOINT")
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
- endpoint = "https://" + endpoint if not endpoint.startswith("http") else endpoint
+ endpoint = (
+ "https://" + endpoint if not endpoint.startswith("http") else endpoint
+ )
self.endpoint = endpoint
macaroon = (
@@ -27,13 +35,13 @@ class LndRestWallet(Wallet):
self.auth = {"Grpc-Metadata-macaroon": macaroon}
self.cert = getenv("LND_REST_CERT")
- def status(self) -> StatusResponse:
+ async def status(self) -> StatusResponse:
try:
- r = httpx.get(
- f"{self.endpoint}/v1/balance/channels",
- headers=self.auth,
- verify=self.cert,
- )
+ async with httpx.AsyncClient(verify=self.cert) as client:
+ r = await client.get(
+ f"{self.endpoint}/v1/balance/channels",
+ headers=self.auth,
+ )
except (httpx.ConnectError, httpx.RequestError):
return StatusResponse(f"Unable to connect to {self.endpoint}.", 0)
@@ -46,24 +54,29 @@ class LndRestWallet(Wallet):
return StatusResponse(None, int(data["balance"]) * 1000)
- def create_invoice(
- self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
+ async def create_invoice(
+ self,
+ amount: int,
+ memo: Optional[str] = None,
+ description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
data: Dict = {
"value": amount,
"private": True,
}
if description_hash:
- data["description_hash"] = base64.b64encode(description_hash).decode("ascii")
+ data["description_hash"] = base64.b64encode(description_hash).decode(
+ "ascii"
+ )
else:
data["memo"] = memo or ""
- r = httpx.post(
- url=f"{self.endpoint}/v1/invoices",
- headers=self.auth,
- verify=self.cert,
- json=data,
- )
+ async with httpx.AsyncClient(verify=self.cert) as client:
+ r = await client.post(
+ url=f"{self.endpoint}/v1/invoices",
+ headers=self.auth,
+ json=data,
+ )
if r.is_error:
error_message = r.text
@@ -80,21 +93,17 @@ class LndRestWallet(Wallet):
return InvoiceResponse(True, checking_id, payment_request, None)
- def pay_invoice(self, bolt11: str) -> PaymentResponse:
- r = httpx.post(
- url=f"{self.endpoint}/v1/channels/transactions",
- headers=self.auth,
- verify=self.cert,
- json={"payment_request": bolt11},
- timeout=180,
- )
+ async def pay_invoice(self, bolt11: str) -> PaymentResponse:
+ async with httpx.AsyncClient(verify=self.cert) as client:
+ r = await client.post(
+ url=f"{self.endpoint}/v1/channels/transactions",
+ headers=self.auth,
+ json={"payment_request": bolt11},
+ timeout=180,
+ )
- if r.is_error:
- error_message = r.text
- try:
- error_message = r.json()["error"]
- except:
- pass
+ if r.is_error or r.json().get("payment_error"):
+ error_message = r.json().get("payment_error") or r.text
return PaymentResponse(False, None, 0, None, error_message)
data = r.json()
@@ -103,13 +112,14 @@ class LndRestWallet(Wallet):
preimage = base64.b64decode(data["payment_preimage"]).hex()
return PaymentResponse(True, checking_id, 0, preimage, None)
- def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
checking_id = checking_id.replace("_", "/")
- r = httpx.get(
- url=f"{self.endpoint}/v1/invoice/{checking_id}",
- headers=self.auth,
- verify=self.cert,
- )
+
+ async with httpx.AsyncClient(verify=self.cert) as client:
+ r = await client.get(
+ url=f"{self.endpoint}/v1/invoice/{checking_id}",
+ headers=self.auth,
+ )
if r.is_error or not r.json().get("settled"):
# this must also work when checking_id is not a hex recognizable by lnd
@@ -118,20 +128,25 @@ class LndRestWallet(Wallet):
return PaymentStatus(True)
- def get_payment_status(self, checking_id: str) -> PaymentStatus:
- r = httpx.get(
- url=f"{self.endpoint}/v1/payments",
- headers=self.auth,
- verify=self.cert,
- params={"max_payments": "20", "reversed": True},
- )
+ async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ async with httpx.AsyncClient(verify=self.cert) as client:
+ r = await client.get(
+ url=f"{self.endpoint}/v1/payments",
+ headers=self.auth,
+ params={"max_payments": "20", "reversed": True},
+ )
if r.is_error:
return PaymentStatus(None)
# check payment.status:
# https://api.lightning.community/rest/index.html?python#peersynctype
- statuses = {"UNKNOWN": None, "IN_FLIGHT": None, "SUCCEEDED": True, "FAILED": False}
+ statuses = {
+ "UNKNOWN": None,
+ "IN_FLIGHT": None,
+ "SUCCEEDED": True,
+ "FAILED": False,
+ }
# for some reason our checking_ids are in base64 but the payment hashes
# returned here are in hex, lnd is weird
diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py
index 9951e4ec..dc4a2e58 100644
--- a/lnbits/wallets/lnpay.py
+++ b/lnbits/wallets/lnpay.py
@@ -6,7 +6,13 @@ from http import HTTPStatus
from typing import Optional, Dict, AsyncGenerator
from quart import request
-from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
+from .base import (
+ StatusResponse,
+ InvoiceResponse,
+ PaymentResponse,
+ PaymentStatus,
+ Wallet,
+)
class LNPayWallet(Wallet):
@@ -18,10 +24,11 @@ class LNPayWallet(Wallet):
self.wallet_key = getenv("LNPAY_WALLET_KEY") or getenv("LNPAY_ADMIN_KEY")
self.auth = {"X-Api-Key": getenv("LNPAY_API_KEY")}
- def status(self) -> StatusResponse:
+ async def status(self) -> StatusResponse:
url = f"{self.endpoint}/wallet/{self.wallet_key}"
try:
- r = httpx.get(url, headers=self.auth, timeout=60)
+ async with httpx.AsyncClient() as client:
+ r = await client.get(url, headers=self.auth, timeout=60)
except (httpx.ConnectError, httpx.RequestError):
return StatusResponse(f"Unable to connect to '{url}'", 0)
@@ -31,12 +38,13 @@ class LNPayWallet(Wallet):
data = r.json()
if data["statusType"]["name"] != "active":
return StatusResponse(
- f"Wallet {data['user_label']} (data['id']) not active, but {data['statusType']['name']}", 0
+ f"Wallet {data['user_label']} (data['id']) not active, but {data['statusType']['name']}",
+ 0,
)
return StatusResponse(None, data["balance"] * 1000)
- def create_invoice(
+ async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
@@ -48,12 +56,13 @@ class LNPayWallet(Wallet):
else:
data["memo"] = memo or ""
- r = httpx.post(
- f"{self.endpoint}/wallet/{self.wallet_key}/invoice",
- headers=self.auth,
- json=data,
- timeout=60,
- )
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.endpoint}/wallet/{self.wallet_key}/invoice",
+ headers=self.auth,
+ json=data,
+ timeout=60,
+ )
ok, checking_id, payment_request, error_message = (
r.status_code == 201,
None,
@@ -67,18 +76,21 @@ class LNPayWallet(Wallet):
return InvoiceResponse(ok, checking_id, payment_request, error_message)
- def pay_invoice(self, bolt11: str) -> PaymentResponse:
- r = httpx.post(
- f"{self.endpoint}/wallet/{self.wallet_key}/withdraw",
- headers=self.auth,
- json={"payment_request": bolt11},
- timeout=180,
- )
+ async def pay_invoice(self, bolt11: str) -> PaymentResponse:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.endpoint}/wallet/{self.wallet_key}/withdraw",
+ headers=self.auth,
+ json={"payment_request": bolt11},
+ timeout=180,
+ )
try:
data = r.json()
except:
- return PaymentResponse(False, None, 0, None, f"Got invalid JSON: {r.text[:200]}")
+ return PaymentResponse(
+ False, None, 0, None, f"Got invalid JSON: {r.text[:200]}"
+ )
if r.is_error:
return PaymentResponse(False, None, 0, None, data["message"])
@@ -88,14 +100,15 @@ class LNPayWallet(Wallet):
preimage = data["lnTx"]["payment_preimage"]
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
- def get_invoice_status(self, checking_id: str) -> PaymentStatus:
- return self.get_payment_status(checking_id)
+ async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ return await self.get_payment_status(checking_id)
- def get_payment_status(self, checking_id: str) -> PaymentStatus:
- r = httpx.get(
- url=f"{self.endpoint}/lntx/{checking_id}?fields=settled",
- headers=self.auth,
- )
+ async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ async with httpx.AsyncClient() as client:
+ r = await client.get(
+ url=f"{self.endpoint}/lntx/{checking_id}?fields=settled",
+ headers=self.auth,
+ )
if r.is_error:
return PaymentStatus(None)
@@ -115,7 +128,11 @@ class LNPayWallet(Wallet):
except json.decoder.JSONDecodeError:
print(f"got something wrong on lnpay webhook endpoint: {text[:200]}")
data = None
- if type(data) is not dict or "event" not in data or data["event"].get("name") != "wallet_receive":
+ 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"]
diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py
index dab12a3f..a346cd43 100644
--- a/lnbits/wallets/lntxbot.py
+++ b/lnbits/wallets/lntxbot.py
@@ -4,7 +4,13 @@ import httpx
from os import getenv
from typing import Optional, Dict, AsyncGenerator
-from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
+from .base import (
+ StatusResponse,
+ InvoiceResponse,
+ PaymentResponse,
+ PaymentStatus,
+ Wallet,
+)
class LntxbotWallet(Wallet):
@@ -14,27 +20,37 @@ class LntxbotWallet(Wallet):
endpoint = getenv("LNTXBOT_API_ENDPOINT")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
- key = getenv("LNTXBOT_KEY") or getenv("LNTXBOT_ADMIN_KEY") or getenv("LNTXBOT_INVOICE_KEY")
+ key = (
+ getenv("LNTXBOT_KEY")
+ or getenv("LNTXBOT_ADMIN_KEY")
+ or getenv("LNTXBOT_INVOICE_KEY")
+ )
self.auth = {"Authorization": f"Basic {key}"}
- def status(self) -> StatusResponse:
- r = httpx.get(
- f"{self.endpoint}/balance",
- headers=self.auth,
- timeout=40,
- )
+ async def status(self) -> StatusResponse:
+ async with httpx.AsyncClient() as client:
+ r = await client.get(
+ f"{self.endpoint}/balance",
+ headers=self.auth,
+ timeout=40,
+ )
try:
data = r.json()
except:
- return StatusResponse(f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0)
+ return StatusResponse(
+ f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0
+ )
if data.get("error"):
return StatusResponse(data["message"], 0)
return StatusResponse(None, data["BTC"]["AvailableBalance"] * 1000)
- def create_invoice(
- self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
+ async def create_invoice(
+ self,
+ amount: int,
+ memo: Optional[str] = None,
+ description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
data: Dict = {"amt": str(amount)}
if description_hash:
@@ -42,12 +58,13 @@ class LntxbotWallet(Wallet):
else:
data["memo"] = memo or ""
- r = httpx.post(
- f"{self.endpoint}/addinvoice",
- headers=self.auth,
- json=data,
- timeout=40,
- )
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.endpoint}/addinvoice",
+ headers=self.auth,
+ json=data,
+ timeout=40,
+ )
if r.is_error:
try:
@@ -62,13 +79,14 @@ class LntxbotWallet(Wallet):
data = r.json()
return InvoiceResponse(True, data["payment_hash"], data["pay_req"], None)
- def pay_invoice(self, bolt11: str) -> PaymentResponse:
- r = httpx.post(
- f"{self.endpoint}/payinvoice",
- headers=self.auth,
- json={"invoice": bolt11},
- timeout=40,
- )
+ async def pay_invoice(self, bolt11: str) -> PaymentResponse:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.endpoint}/payinvoice",
+ headers=self.auth,
+ json={"invoice": bolt11},
+ timeout=40,
+ )
if r.is_error:
try:
@@ -82,15 +100,16 @@ class LntxbotWallet(Wallet):
data = r.json()
checking_id = data["payment_hash"]
- fee_msat = data["fee_msat"]
+ fee_msat = -data["fee_msat"]
preimage = data["payment_preimage"]
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
- def get_invoice_status(self, checking_id: str) -> PaymentStatus:
- r = httpx.post(
- f"{self.endpoint}/invoicestatus/{checking_id}?wait=false",
- headers=self.auth,
- )
+ async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.endpoint}/invoicestatus/{checking_id}?wait=false",
+ headers=self.auth,
+ )
data = r.json()
if r.is_error or "error" in data:
@@ -101,11 +120,12 @@ class LntxbotWallet(Wallet):
return PaymentStatus(True)
- def get_payment_status(self, checking_id: str) -> PaymentStatus:
- r = httpx.post(
- url=f"{self.endpoint}/paymentstatus/{checking_id}",
- headers=self.auth,
- )
+ async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ url=f"{self.endpoint}/paymentstatus/{checking_id}",
+ headers=self.auth,
+ )
data = r.json()
if r.is_error or "error" in data:
diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py
index 97b395d0..8354e819 100644
--- a/lnbits/wallets/opennode.py
+++ b/lnbits/wallets/opennode.py
@@ -1,4 +1,3 @@
-import json
import trio # type: ignore
import hmac
import httpx
@@ -7,7 +6,14 @@ from os import getenv
from typing import Optional, AsyncGenerator
from quart import request, url_for
-from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
+from .base import (
+ StatusResponse,
+ InvoiceResponse,
+ PaymentResponse,
+ PaymentStatus,
+ Wallet,
+ Unsupported,
+)
class OpenNodeWallet(Wallet):
@@ -17,16 +23,21 @@ class OpenNodeWallet(Wallet):
endpoint = getenv("OPENNODE_API_ENDPOINT")
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
- key = getenv("OPENNODE_KEY") or getenv("OPENNODE_ADMIN_KEY") or getenv("OPENNODE_INVOICE_KEY")
+ key = (
+ getenv("OPENNODE_KEY")
+ or getenv("OPENNODE_ADMIN_KEY")
+ or getenv("OPENNODE_INVOICE_KEY")
+ )
self.auth = {"Authorization": key}
- def status(self) -> StatusResponse:
+ async def status(self) -> StatusResponse:
try:
- r = httpx.get(
- f"{self.endpoint}/v1/account/balance",
- headers=self.auth,
- timeout=40,
- )
+ async with httpx.AsyncClient() as client:
+ r = await client.get(
+ f"{self.endpoint}/v1/account/balance",
+ headers=self.auth,
+ timeout=40,
+ )
except (httpx.ConnectError, httpx.RequestError):
return StatusResponse(f"Unable to connect to '{self.endpoint}'", 0)
@@ -36,22 +47,26 @@ class OpenNodeWallet(Wallet):
return StatusResponse(None, data["balance"]["BTC"] / 100_000_000_000)
- def create_invoice(
- self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
+ async def create_invoice(
+ self,
+ amount: int,
+ memo: Optional[str] = None,
+ description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
if description_hash:
raise Unsupported("description_hash")
- r = httpx.post(
- f"{self.endpoint}/v1/charges",
- headers=self.auth,
- json={
- "amount": amount,
- "description": memo or "",
- "callback_url": url_for("webhook_listener", _external=True),
- },
- timeout=40,
- )
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.endpoint}/v1/charges",
+ headers=self.auth,
+ json={
+ "amount": amount,
+ "description": memo or "",
+ "callback_url": url_for("webhook_listener", _external=True),
+ },
+ timeout=40,
+ )
if r.is_error:
error_message = r.json()["message"]
@@ -62,13 +77,14 @@ class OpenNodeWallet(Wallet):
payment_request = data["lightning_invoice"]["payreq"]
return InvoiceResponse(True, checking_id, payment_request, None)
- def pay_invoice(self, bolt11: str) -> PaymentResponse:
- r = httpx.post(
- f"{self.endpoint}/v2/withdrawals",
- headers=self.auth,
- json={"type": "ln", "address": bolt11},
- timeout=180,
- )
+ async def pay_invoice(self, bolt11: str) -> PaymentResponse:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.endpoint}/v2/withdrawals",
+ headers=self.auth,
+ json={"type": "ln", "address": bolt11},
+ timeout=180,
+ )
if r.is_error:
error_message = r.json()["message"]
@@ -79,21 +95,33 @@ class OpenNodeWallet(Wallet):
fee_msat = data["fee"] * 1000
return PaymentResponse(True, checking_id, fee_msat, None, None)
- def get_invoice_status(self, checking_id: str) -> PaymentStatus:
- r = httpx.get(f"{self.endpoint}/v1/charge/{checking_id}", headers=self.auth)
+ async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ async with httpx.AsyncClient() as client:
+ r = await client.get(
+ f"{self.endpoint}/v1/charge/{checking_id}", headers=self.auth
+ )
if r.is_error:
return PaymentStatus(None)
statuses = {"processing": None, "paid": True, "unpaid": False}
return PaymentStatus(statuses[r.json()["data"]["status"]])
- def get_payment_status(self, checking_id: str) -> PaymentStatus:
- r = httpx.get(f"{self.endpoint}/v1/withdrawal/{checking_id}", headers=self.auth)
+ async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ async with httpx.AsyncClient() as client:
+ r = await client.get(
+ f"{self.endpoint}/v1/withdrawal/{checking_id}", headers=self.auth
+ )
if r.is_error:
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"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py
index 5ab40ddd..5d0f024a 100644
--- a/lnbits/wallets/spark.py
+++ b/lnbits/wallets/spark.py
@@ -1,11 +1,17 @@
import trio # type: ignore
-import random
import json
import httpx
+import random
from os import getenv
from typing import Optional, AsyncGenerator
-from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
+from .base import (
+ StatusResponse,
+ InvoiceResponse,
+ PaymentResponse,
+ PaymentStatus,
+ Wallet,
+)
class SparkError(Exception):
@@ -22,9 +28,11 @@ class SparkWallet(Wallet):
self.token = getenv("SPARK_TOKEN")
def __getattr__(self, key):
- def call(*args, **kwargs):
+ async def call(*args, **kwargs):
if args and kwargs:
- raise TypeError(f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}")
+ raise TypeError(
+ f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}"
+ )
elif args:
params = args
elif kwargs:
@@ -32,12 +40,17 @@ class SparkWallet(Wallet):
else:
params = {}
- r = httpx.post(
- self.url + "/rpc",
- headers={"X-Access": self.token},
- json={"method": key, "params": params},
- timeout=40,
- )
+ try:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ self.url + "/rpc",
+ headers={"X-Access": self.token},
+ json={"method": key, "params": params},
+ timeout=40,
+ )
+ except (OSError, httpx.ConnectError, httpx.RequestError) as exc:
+ raise UnknownError("error connecting to spark: " + str(exc))
+
try:
data = r.json()
except:
@@ -53,9 +66,9 @@ class SparkWallet(Wallet):
return call
- def status(self) -> StatusResponse:
+ async def status(self) -> StatusResponse:
try:
- funds = self.listfunds()
+ funds = await self.listfunds()
except (httpx.ConnectError, httpx.RequestError):
return StatusResponse("Couldn't connect to Spark server", 0)
except (SparkError, UnknownError) as e:
@@ -66,22 +79,28 @@ class SparkWallet(Wallet):
sum([ch["channel_sat"] * 1000 for ch in funds["channels"]]),
)
- def create_invoice(
- self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
+ async def create_invoice(
+ self,
+ amount: int,
+ memo: Optional[str] = None,
+ description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
label = "lbs{}".format(random.random())
checking_id = label
try:
if description_hash:
- r = self.invoicewithdescriptionhash(
+ r = await self.invoicewithdescriptionhash(
msatoshi=amount * 1000,
label=label,
description_hash=description_hash.hex(),
)
else:
- r = self.invoice(
- msatoshi=amount * 1000, label=label, description=memo or "", exposeprivatechannels=True
+ r = await self.invoice(
+ msatoshi=amount * 1000,
+ label=label,
+ description=memo or "",
+ exposeprivatechannels=True,
)
ok, payment_request, error_message = True, r["bolt11"], ""
except (SparkError, UnknownError) as e:
@@ -89,26 +108,70 @@ class SparkWallet(Wallet):
return InvoiceResponse(ok, checking_id, payment_request, error_message)
- def pay_invoice(self, bolt11: str) -> PaymentResponse:
+ async def pay_invoice(self, bolt11: str) -> PaymentResponse:
try:
- r = self.pay(bolt11)
+ r = await self.pay(bolt11)
except (SparkError, UnknownError) as exc:
- return PaymentResponse(False, None, 0, None, str(exc))
+ listpays = await self.listpays(bolt11)
+ if listpays:
+ pays = listpays["pays"]
+
+ if len(pays) == 0:
+ return PaymentResponse(False, None, 0, None, str(exc))
+
+ pay = pays[0]
+ payment_hash = pay["payment_hash"]
+
+ if len(pays) > 1:
+ raise SparkError(
+ f"listpays({payment_hash}) returned an unexpected response: {listpays}"
+ )
+
+ if pay["status"] == "failed":
+ return PaymentResponse(False, None, 0, None, str(exc))
+ elif pay["status"] == "pending":
+ return PaymentResponse(None, payment_hash, 0, None, None)
+ elif pay["status"] == "complete":
+ r = pay
+ r["payment_preimage"] = pay["preimage"]
+ r["msatoshi"] = int(pay["amount_msat"][0:-4])
+ r["msatoshi_sent"] = int(pay["amount_sent_msat"][0:-4])
+ # this may result in an error if it was paid previously
+ # our database won't allow the same payment_hash to be added twice
+ # this is good
+ pass
fee_msat = r["msatoshi_sent"] - r["msatoshi"]
preimage = r["payment_preimage"]
return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None)
- def get_invoice_status(self, checking_id: str) -> PaymentStatus:
- r = self.listinvoices(label=checking_id)
+ async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ try:
+ r = await self.listinvoices(label=checking_id)
+ except (SparkError, UnknownError):
+ return PaymentStatus(None)
+
if not r or not r.get("invoices"):
return PaymentStatus(None)
if r["invoices"][0]["status"] == "unpaid":
return PaymentStatus(False)
return PaymentStatus(True)
- def get_payment_status(self, checking_id: str) -> PaymentStatus:
- r = self.listpays(payment_hash=checking_id)
+ async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ # check if it's 32 bytes hex
+ if len(checking_id) != 64:
+ return PaymentStatus(None)
+ try:
+ int(checking_id, 16)
+ except ValueError:
+ return PaymentStatus(None)
+
+ # ask sparko
+ try:
+ r = await self.listpays(payment_hash=checking_id)
+ except (SparkError, UnknownError):
+ return PaymentStatus(None)
+
if not r["pays"]:
return PaymentStatus(False)
if r["pays"][0]["payment_hash"] == checking_id:
@@ -132,7 +195,7 @@ class SparkWallet(Wallet):
data = json.loads(line[5:])
if "pay_index" in data and data.get("status") == "paid":
yield data["label"]
- except (OSError, httpx.ReadError):
+ except (OSError, httpx.ReadError, httpx.ConnectError):
pass
print("lost connection to spark /stream, retrying in 5 seconds")
diff --git a/lnbits/wallets/void.py b/lnbits/wallets/void.py
index b6617363..591fa042 100644
--- a/lnbits/wallets/void.py
+++ b/lnbits/wallets/void.py
@@ -1,27 +1,37 @@
from typing import Optional, AsyncGenerator
-from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
+from .base import (
+ StatusResponse,
+ InvoiceResponse,
+ PaymentResponse,
+ PaymentStatus,
+ Wallet,
+ Unsupported,
+)
class VoidWallet(Wallet):
- def create_invoice(
- self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None
+ async def create_invoice(
+ self,
+ amount: int,
+ memo: Optional[str] = None,
+ description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
raise Unsupported("")
- def status(self) -> StatusResponse:
+ async def status(self) -> StatusResponse:
return StatusResponse(
"This backend does nothing, it is here just as a placeholder, you must configure an actual backend before being able to do anything useful with LNbits.",
0,
)
- def pay_invoice(self, bolt11: str) -> PaymentResponse:
+ async def pay_invoice(self, bolt11: str) -> PaymentResponse:
raise Unsupported("")
- def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
raise Unsupported("")
- def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ async def get_payment_status(self, checking_id: str) -> PaymentStatus:
raise Unsupported("")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index 55ec8d78..00000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,2 +0,0 @@
-[tool.black]
-line-length = 120
diff --git a/requirements.txt b/requirements.txt
index 18250051..fec167af 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,40 +6,42 @@ bitstring==3.1.7
blinker==1.4
brotli==1.0.9
cerberus==1.3.2
-certifi==2020.11.8
+certifi==2020.12.5
click==7.1.2
ecdsa==0.16.1
-environs==9.2.0
-h11==0.11.0
+environs==9.3.1
+h11==0.12.0
h2==4.0.0
hpack==4.0.0
-httpcore==0.12.2
+httpcore==0.12.3
httpx==0.16.1
-hypercorn==0.11.1
+hypercorn==0.11.2
hyperframe==6.0.0
-idna==2.10
+idna==3.1
itsdangerous==1.1.0
-jinja2==2.11.2
+jinja2==2.11.3
lnurl==0.3.5
markupsafe==1.1.1
-marshmallow==3.9.1
+marshmallow==3.10.0
outcome==1.1.0
priority==1.3.0
-pydantic==1.7.2
+pydantic==1.8
+pypng==0.0.20
+pyqrcode==1.2.1
pyscss==1.3.7
python-dotenv==0.15.0
-quart==0.13.1
+quart==0.14.1
quart-compress==0.2.1
quart-cors==0.3.0
-quart-trio==0.6.0
-represent==1.6.0
+quart-trio==0.7.0
+represent==1.6.0.post0
rfc3986==1.4.0
secure==0.2.1
shortuuid==1.0.1
six==1.15.0
sniffio==1.2.0
sortedcontainers==2.3.0
-sqlalchemy==1.3.20
+sqlalchemy==1.3.23
sqlalchemy-aio==0.16.0
toml==0.10.2
trio==0.16.0