diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 4d6c6d4da..71713d88f 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -5,10 +5,9 @@ on: [push, pull_request] jobs: check: runs-on: ubuntu-latest - if: ${{ 'false' == 'true' }} # skip mypy for now steps: - uses: actions/checkout@v1 - uses: jpetrucciani/mypy-check@master with: mypy_flags: '--install-types --non-interactive' - path: lnbits + path: 'lnbits' diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index fb114ff8d..0d9afdac7 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -18,7 +18,6 @@ jobs: run: | git clone https://github.com/lnbits/legend-regtest-enviroment.git docker cd docker - git checkout removelnbits chmod +x ./tests ./tests sudo chmod -R a+rwx . @@ -59,7 +58,6 @@ jobs: run: | git clone https://github.com/lnbits/legend-regtest-enviroment.git docker cd docker - git checkout removelnbits chmod +x ./tests ./tests sudo chmod -R a+rwx . diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 218a557bc..15a2388a9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,11 +68,11 @@ jobs: uses: codecov/codecov-action@v3 with: file: ./coverage.xml - pipenv-sqlite: + poetry-sqlite: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -80,9 +80,56 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies + env: + VIRTUAL_ENV: ./venv + PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }} run: | - pip install pipenv - pipenv install --dev - pipenv install importlib-metadata + python -m venv ${{ env.VIRTUAL_ENV }} + ./venv/bin/python -m pip install --upgrade pip + ./venv/bin/pip install -r requirements.txt + ./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock - name: Run tests - run: make test-pipenv \ No newline at end of file + run: make test + poetry-postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + # maps tcp port 5432 on service container to the host + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + matrix: + python-version: [3.9] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + env: + VIRTUAL_ENV: ./venv + PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }} + run: | + python -m venv ${{ env.VIRTUAL_ENV }} + ./venv/bin/python -m pip install --upgrade pip + ./venv/bin/pip install -r requirements.txt + ./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock + - name: Run tests + env: + LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres + run: make test + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml \ No newline at end of file diff --git a/Pipfile b/Pipfile deleted file mode 100644 index f8c42a9dd..000000000 --- a/Pipfile +++ /dev/null @@ -1,44 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[requires] -python_version = "3.8" - -[packages] -bitstring = "*" -cerberus = "*" -ecdsa = "*" -environs = "*" -lnurl = "==0.3.6" -loguru = "*" -pyscss = "*" -shortuuid = "*" -typing-extensions = "*" -httpx = "*" -sqlalchemy-aio = "*" -embit = "*" -pyqrcode = "*" -pypng = "*" -sqlalchemy = "==1.3.23" -psycopg2-binary = "*" -aiofiles = "*" -asyncio = "*" -fastapi = "*" -uvicorn = {extras = ["standard"], version = "*"} -sse-starlette = "*" -jinja2 = "==3.0.1" -pyngrok = "*" -secp256k1 = "==0.14.0" -cffi = "==1.15.0" -pycryptodomex = "*" - -[dev-packages] -black = "==20.8b1" -pytest = "*" -pytest-cov = "*" -mypy = "*" -pytest-asyncio = "*" -requests = "*" -mock = "*" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 42d471c6f..000000000 --- a/Pipfile.lock +++ /dev/null @@ -1,1157 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "503e9942306106e40621c59f37a3ab866b483f8c5f27b879c1c6783dca30949f" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.8" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "aiofiles": { - "hashes": [ - "sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937", - "sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59" - ], - "index": "pypi", - "version": "==0.8.0" - }, - "anyio": { - "hashes": [ - "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b", - "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be" - ], - "version": "==3.6.1" - }, - "asyncio": { - "hashes": [ - "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41", - "sha256:b62c9157d36187eca799c378e572c969f0da87cd5fc42ca372d92cdb06e7e1de", - "sha256:c46a87b48213d7464f22d9a497b9eef8c1928b68320a2fa94240f969f6fec08c", - "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d" - ], - "index": "pypi", - "version": "==3.4.3" - }, - "attrs": { - "hashes": [ - "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", - "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" - ], - "version": "==21.4.0" - }, - "bech32": { - "hashes": [ - "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899", - "sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981" - ], - "version": "==1.2.0" - }, - "bitstring": { - "hashes": [ - "sha256:0de167daa6a00c9386255a7cac931b45e6e24e0ad7ea64f1f92a64ac23ad4578", - "sha256:a5848a3f63111785224dca8bb4c0a75b62ecdef56a042c8d6be74b16f7e860e7", - "sha256:e3e340e58900a948787a05e8c08772f1ccbe133f6f41fe3f0fa19a18a22bbf4f" - ], - "index": "pypi", - "version": "==3.1.9" - }, - "cerberus": { - "hashes": [ - "sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c" - ], - "index": "pypi", - "version": "==1.3.4" - }, - "certifi": { - "hashes": [ - "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", - "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" - ], - "version": "==2022.6.15" - }, - "cffi": { - "hashes": [ - "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3", - "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2", - "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636", - "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20", - "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728", - "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27", - "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66", - "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443", - "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0", - "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7", - "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39", - "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605", - "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a", - "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37", - "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029", - "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139", - "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc", - "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df", - "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14", - "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880", - "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2", - "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a", - "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e", - "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474", - "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024", - "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8", - "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0", - "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e", - "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a", - "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e", - "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032", - "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6", - "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e", - "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b", - "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e", - "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954", - "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962", - "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c", - "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4", - "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55", - "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962", - "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023", - "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c", - "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6", - "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8", - "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382", - "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7", - "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc", - "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997", - "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796" - ], - "index": "pypi", - "version": "==1.15.0" - }, - "click": { - "hashes": [ - "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", - "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" - ], - "version": "==8.1.3" - }, - "ecdsa": { - "hashes": [ - "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49", - "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd" - ], - "index": "pypi", - "version": "==0.18.0" - }, - "embit": { - "hashes": [ - "sha256:5644ae6ed07bb71bf7fb15daf7f5af73d889180e623f5ff1f35a20ad01f0405e" - ], - "index": "pypi", - "version": "==0.5.0" - }, - "environs": { - "hashes": [ - "sha256:1e549569a3de49c05f856f40bce86979e7d5ffbbc4398e7f338574c220189124", - "sha256:a76307b36fbe856bdca7ee9161e6c466fd7fcffc297109a118c59b54e27e30c9" - ], - "index": "pypi", - "version": "==9.5.0" - }, - "fastapi": { - "hashes": [ - "sha256:cf0ff6db25b91d321050c4112baab0908c90f19b40bf257f9591d2f9780d1f22", - "sha256:d337563424ceada23857f73d5abe8dae0c28e4cccb53b2af06e78b7bb4a1c7d7" - ], - "index": "pypi", - "version": "==0.79.0" - }, - "h11": { - "hashes": [ - "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", - "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" - ], - "version": "==0.12.0" - }, - "httpcore": { - "hashes": [ - "sha256:1105b8b73c025f23ff7c36468e4432226cbb959176eab66864b8e31c4ee27fa6", - "sha256:18b68ab86a3ccf3e7dc0f43598eaddcf472b602aba29f9aa6ab85fe2ada3980b" - ], - "version": "==0.15.0" - }, - "httptools": { - "hashes": [ - "sha256:1a99346ebcb801b213c591540837340bdf6fd060a8687518d01c607d338b7424", - "sha256:1ee0b459257e222b878a6c09ccf233957d3a4dcb883b0847640af98d2d9aac23", - "sha256:20a45bcf22452a10fa8d58b7dbdb474381f6946bf5b8933e3662d572bc61bae4", - "sha256:29bf97a5c532da9c7a04de2c7a9c31d1d54f3abd65a464119b680206bbbb1055", - "sha256:2c9a930c378b3d15d6b695fb95ebcff81a7395b4f9775c4f10a076beb0b2c1ff", - "sha256:2db44a0b294d317199e9f80123e72c6b005c55b625b57fae36de68670090fa48", - "sha256:3194f6d6443befa8d4db16c1946b2fc428a3ceb8ab32eb6f09a59f86104dc1a0", - "sha256:34d2903dd2a3dd85d33705b6fde40bf91fc44411661283763fd0746723963c83", - "sha256:48e48530d9b995a84d1d89ae6b3ec4e59ea7d494b150ac3bbc5e2ac4acce92cd", - "sha256:54bbd295f031b866b9799dd39cb45deee81aca036c9bff9f58ca06726f6494f1", - "sha256:5d1fe6b6661022fd6cac541f54a4237496b246e6f1c0a6b41998ee08a1135afe", - "sha256:645373c070080e632480a3d251d892cb795be3d3a15f86975d0f1aca56fd230d", - "sha256:6a1a7dfc1f9c78a833e2c4904757a0f47ce25d08634dd2a52af394eefe5f9777", - "sha256:701e66b59dd21a32a274771238025d58db7e2b6ecebbab64ceff51b8e31527ae", - "sha256:72aa3fbe636b16d22e04b5a9d24711b043495e0ecfe58080addf23a1a37f3409", - "sha256:7af6bdbd21a2a25d6784f6d67f44f5df33ef39b6159543b9f9064d365c01f919", - "sha256:7ee9f226acab9085037582c059d66769862706e8e8cd2340470ceb8b3850873d", - "sha256:7f7bfb74718f52d5ed47d608d507bf66d3bc01d4a8b3e6dd7134daaae129357b", - "sha256:8e2eb957787cbb614a0f006bfc5798ff1d90ac7c4dd24854c84edbdc8c02369e", - "sha256:903f739c9fb78dab8970b0f3ea51f21955b24b45afa77b22ff0e172fc11ef111", - "sha256:98993805f1e3cdb53de4eed02b55dcc953cdf017ba7bbb2fd89226c086a6d855", - "sha256:9967d9758df505975913304c434cb9ab21e2c609ad859eb921f2f615a038c8de", - "sha256:a113789e53ac1fa26edf99856a61e4c493868e125ae0dd6354cf518948fbbd5c", - "sha256:a522d12e2ddbc2e91842ffb454a1aeb0d47607972c7d8fc88bd0838d97fb8a2a", - "sha256:abe829275cdd4174b4c4e65ad718715d449e308d59793bf3a931ee1bf7e7b86c", - "sha256:c286985b5e194ca0ebb2908d71464b9be8f17cc66d6d3e330e8d5407248f56ad", - "sha256:cd1295f52971097f757edfbfce827b6dbbfb0f7a74901ee7d4933dff5ad4c9af", - "sha256:ceafd5e960b39c7e0d160a1936b68eb87c5e79b3979d66e774f0c77d4d8faaed", - "sha256:d1f27bb0f75bef722d6e22dc609612bfa2f994541621cd2163f8c943b6463dfe", - "sha256:d3a4e165ca6204f34856b765d515d558dc84f1352033b8721e8d06c3e44930c3", - "sha256:d9b90bf58f3ba04e60321a23a8723a1ff2a9377502535e70495e5ada8e6e6722", - "sha256:f72b5d24d6730035128b238decdc4c0f2104b7056a7ca55cf047c106842ec890", - "sha256:fcddfe70553be717d9745990dfdb194e22ee0f60eb8f48c0794e7bfeda30d2d5", - "sha256:fdb9f9ed79bc6f46b021b3319184699ba1a22410a82204e6e89c774530069683" - ], - "version": "==0.4.0" - }, - "httpx": { - "hashes": [ - "sha256:42974f577483e1e932c3cdc3cd2303e883cbfba17fe228b0f63589764d7b9c4b", - "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef" - ], - "index": "pypi", - "version": "==0.23.0" - }, - "idna": { - "hashes": [ - "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", - "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" - ], - "version": "==3.3" - }, - "jinja2": { - "hashes": [ - "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", - "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" - ], - "index": "pypi", - "version": "==3.0.1" - }, - "lnurl": { - "hashes": [ - "sha256:579982fd8c4d25bc84c61c74ec45cb7999fa1fa2426f5d5aeb0160ba333b9c92", - "sha256:8af07460115a48f3122a5a9c9a6062bee3897d5f6ab4c9a60f6561a83a8234f6" - ], - "index": "pypi", - "version": "==0.3.6" - }, - "loguru": { - "hashes": [ - "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c", - "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3" - ], - "index": "pypi", - "version": "==0.6.0" - }, - "markupsafe": { - "hashes": [ - "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", - "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", - "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", - "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", - "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", - "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", - "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", - "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", - "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", - "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", - "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", - "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", - "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", - "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", - "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", - "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", - "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", - "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", - "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", - "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", - "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", - "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", - "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", - "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", - "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", - "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", - "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", - "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", - "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", - "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", - "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", - "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", - "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", - "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", - "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", - "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", - "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", - "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", - "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", - "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" - ], - "version": "==2.1.1" - }, - "marshmallow": { - "hashes": [ - "sha256:00040ab5ea0c608e8787137627a8efae97fabd60552a05dc889c888f814e75eb", - "sha256:635fb65a3285a31a30f276f30e958070f5214c7196202caa5c7ecf28f5274bc7" - ], - "version": "==3.17.0" - }, - "outcome": { - "hashes": [ - "sha256:6f82bd3de45da303cf1f771ecafa1633750a358436a8bb60e06a1ceb745d2672", - "sha256:c4ab89a56575d6d38a05aa16daeaa333109c1f96167aba8901ab18b6b5e0f7f5" - ], - "version": "==1.2.0" - }, - "packaging": { - "hashes": [ - "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", - "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" - ], - "version": "==21.3" - }, - "psycopg2-binary": { - "hashes": [ - "sha256:01310cf4cf26db9aea5158c217caa92d291f0500051a6469ac52166e1a16f5b7", - "sha256:083a55275f09a62b8ca4902dd11f4b33075b743cf0d360419e2051a8a5d5ff76", - "sha256:090f3348c0ab2cceb6dfbe6bf721ef61262ddf518cd6cc6ecc7d334996d64efa", - "sha256:0a29729145aaaf1ad8bafe663131890e2111f13416b60e460dae0a96af5905c9", - "sha256:0c9d5450c566c80c396b7402895c4369a410cab5a82707b11aee1e624da7d004", - "sha256:10bb90fb4d523a2aa67773d4ff2b833ec00857f5912bafcfd5f5414e45280fb1", - "sha256:12b11322ea00ad8db8c46f18b7dfc47ae215e4df55b46c67a94b4effbaec7094", - "sha256:152f09f57417b831418304c7f30d727dc83a12761627bb826951692cc6491e57", - "sha256:15803fa813ea05bef089fa78835118b5434204f3a17cb9f1e5dbfd0b9deea5af", - "sha256:15c4e4cfa45f5a60599d9cec5f46cd7b1b29d86a6390ec23e8eebaae84e64554", - "sha256:183a517a3a63503f70f808b58bfbf962f23d73b6dccddae5aa56152ef2bcb232", - "sha256:1f14c8b0942714eb3c74e1e71700cbbcb415acbc311c730370e70c578a44a25c", - "sha256:1f6b813106a3abdf7b03640d36e24669234120c72e91d5cbaeb87c5f7c36c65b", - "sha256:280b0bb5cbfe8039205c7981cceb006156a675362a00fe29b16fbc264e242834", - "sha256:2d872e3c9d5d075a2e104540965a1cf898b52274a5923936e5bfddb58c59c7c2", - "sha256:2f9ffd643bc7349eeb664eba8864d9e01f057880f510e4681ba40a6532f93c71", - "sha256:3303f8807f342641851578ee7ed1f3efc9802d00a6f83c101d21c608cb864460", - "sha256:35168209c9d51b145e459e05c31a9eaeffa9a6b0fd61689b48e07464ffd1a83e", - "sha256:3a79d622f5206d695d7824cbf609a4f5b88ea6d6dab5f7c147fc6d333a8787e4", - "sha256:404224e5fef3b193f892abdbf8961ce20e0b6642886cfe1fe1923f41aaa75c9d", - "sha256:46f0e0a6b5fa5851bbd9ab1bc805eef362d3a230fbdfbc209f4a236d0a7a990d", - "sha256:47133f3f872faf28c1e87d4357220e809dfd3fa7c64295a4a148bcd1e6e34ec9", - "sha256:526ea0378246d9b080148f2d6681229f4b5964543c170dd10bf4faaab6e0d27f", - "sha256:53293533fcbb94c202b7c800a12c873cfe24599656b341f56e71dd2b557be063", - "sha256:539b28661b71da7c0e428692438efbcd048ca21ea81af618d845e06ebfd29478", - "sha256:57804fc02ca3ce0dbfbef35c4b3a4a774da66d66ea20f4bda601294ad2ea6092", - "sha256:63638d875be8c2784cfc952c9ac34e2b50e43f9f0a0660b65e2a87d656b3116c", - "sha256:6472a178e291b59e7f16ab49ec8b4f3bdada0a879c68d3817ff0963e722a82ce", - "sha256:68641a34023d306be959101b345732360fc2ea4938982309b786f7be1b43a4a1", - "sha256:6e82d38390a03da28c7985b394ec3f56873174e2c88130e6966cb1c946508e65", - "sha256:761df5313dc15da1502b21453642d7599d26be88bff659382f8f9747c7ebea4e", - "sha256:7af0dd86ddb2f8af5da57a976d27cd2cd15510518d582b478fbb2292428710b4", - "sha256:7b1e9b80afca7b7a386ef087db614faebbf8839b7f4db5eb107d0f1a53225029", - "sha256:874a52ecab70af13e899f7847b3e074eeb16ebac5615665db33bce8a1009cf33", - "sha256:887dd9aac71765ac0d0bac1d0d4b4f2c99d5f5c1382d8b770404f0f3d0ce8a39", - "sha256:8b344adbb9a862de0c635f4f0425b7958bf5a4b927c8594e6e8d261775796d53", - "sha256:8fc53f9af09426a61db9ba357865c77f26076d48669f2e1bb24d85a22fb52307", - "sha256:91920527dea30175cc02a1099f331aa8c1ba39bf8b7762b7b56cbf54bc5cce42", - "sha256:93cd1967a18aa0edd4b95b1dfd554cf15af657cb606280996d393dadc88c3c35", - "sha256:99485cab9ba0fa9b84f1f9e1fef106f44a46ef6afdeec8885e0b88d0772b49e8", - "sha256:9d29409b625a143649d03d0fd7b57e4b92e0ecad9726ba682244b73be91d2fdb", - "sha256:a29b3ca4ec9defec6d42bf5feb36bb5817ba3c0230dd83b4edf4bf02684cd0ae", - "sha256:a9e1f75f96ea388fbcef36c70640c4efbe4650658f3d6a2967b4cc70e907352e", - "sha256:accfe7e982411da3178ec690baaceaad3c278652998b2c45828aaac66cd8285f", - "sha256:adf20d9a67e0b6393eac162eb81fb10bc9130a80540f4df7e7355c2dd4af9fba", - "sha256:af9813db73395fb1fc211bac696faea4ca9ef53f32dc0cfa27e4e7cf766dcf24", - "sha256:b1c8068513f5b158cf7e29c43a77eb34b407db29aca749d3eb9293ee0d3103ca", - "sha256:bda845b664bb6c91446ca9609fc69f7db6c334ec5e4adc87571c34e4f47b7ddb", - "sha256:c381bda330ddf2fccbafab789d83ebc6c53db126e4383e73794c74eedce855ef", - "sha256:c3ae8e75eb7160851e59adc77b3a19a976e50622e44fd4fd47b8b18208189d42", - "sha256:d1c1b569ecafe3a69380a94e6ae09a4789bbb23666f3d3a08d06bbd2451f5ef1", - "sha256:def68d7c21984b0f8218e8a15d514f714d96904265164f75f8d3a70f9c295667", - "sha256:dffc08ca91c9ac09008870c9eb77b00a46b3378719584059c034b8945e26b272", - "sha256:e3699852e22aa68c10de06524a3721ade969abf382da95884e6a10ff798f9281", - "sha256:e847774f8ffd5b398a75bc1c18fbb56564cda3d629fe68fd81971fece2d3c67e", - "sha256:ffb7a888a047696e7f8240d649b43fb3644f14f0ee229077e7f6b9f9081635bd" - ], - "index": "pypi", - "version": "==2.9.3" - }, - "pycparser": { - "hashes": [ - "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", - "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" - ], - "version": "==2.21" - }, - "pycryptodomex": { - "hashes": [ - "sha256:04cc393045a8f19dd110c975e30f38ed7ab3faf21ede415ea67afebd95a22380", - "sha256:0776bfaf2c48154ab54ea45392847c1283d2fcf64e232e85565f858baedfc1fa", - "sha256:0fadb9f7fa3150577800eef35f62a8a24b9ddf1563ff060d9bd3af22d3952c8c", - "sha256:18e2ab4813883ae63396c0ffe50b13554b32bb69ec56f0afaf052e7a7ae0d55b", - "sha256:191e73bc84a8064ad1874dba0ebadedd7cce4dedee998549518f2c74a003b2e1", - "sha256:35a8f7afe1867118330e2e0e0bf759c409e28557fb1fc2fbb1c6c937297dbe9a", - "sha256:3709f13ca3852b0b07fc04a2c03b379189232b24007c466be0f605dd4723e9d4", - "sha256:4540904c09704b6f831059c0dfb38584acb82cb97b0125cd52688c1f1e3fffa6", - "sha256:463119d7d22d0fc04a0f9122e9d3e6121c6648bcb12a052b51bd1eed1b996aa2", - "sha256:46b3f05f2f7ac7841053da4e0f69616929ca3c42f238c405f6c3df7759ad2780", - "sha256:48697790203909fab02a33226fda546604f4e2653f9d47bc5d3eb40879fa7c64", - "sha256:5676a132169a1c1a3712edf25250722ebc8c9102aa9abd814df063ca8362454f", - "sha256:65204412d0c6a8e3c41e21e93a5e6054a74fea501afa03046a388cf042e3377a", - "sha256:67e1e6a92151023ccdfcfbc0afb3314ad30080793b4c27956ea06ab1fb9bcd8a", - "sha256:6f5b6ba8aefd624834bc177a2ac292734996bb030f9d1b388e7504103b6fcddf", - "sha256:7341f1bb2dadb0d1a0047f34c3a58208a92423cdbd3244d998e4b28df5eac0ed", - "sha256:78d9621cf0ea35abf2d38fa2ca6d0634eab6c991a78373498ab149953787e5e5", - "sha256:8eecdf9cdc7343001d047f951b9cc805cd68cb6cd77b20ea46af5bffc5bd3dfb", - "sha256:94c7b60e1f52e1a87715571327baea0733708ab4723346598beca4a3b6879794", - "sha256:996e1ba717077ce1e6d4849af7a1426f38b07b3d173b879e27d5e26d2e958beb", - "sha256:a07a64709e366c2041cd5cfbca592b43998bf4df88f7b0ca73dca37071ccf1bd", - "sha256:b6306403228edde6e289f626a3908a2f7f67c344e712cf7c0a508bab3ad9e381", - "sha256:b9279adc16e4b0f590ceff581f53a80179b02cba9056010d733eb4196134a870", - "sha256:c4cb9cb492ea7dcdf222a8d19a1d09002798ea516aeae8877245206d27326d86", - "sha256:dd452a5af7014e866206d41751886c9b4bf379a339fdf2dbfc7dd16c0fb4f8e0", - "sha256:e2b12968522a0358b8917fc7b28865acac002f02f4c4c6020fcb264d76bfd06d", - "sha256:e3164a18348bd53c69b4435ebfb4ac8a4076291ffa2a70b54f0c4b80c7834b1d", - "sha256:e47bf8776a7e15576887f04314f5228c6527b99946e6638cf2f16da56d260cab", - "sha256:f8be976cec59b11f011f790b88aca67b4ea2bd286578d0bd3e31bcd19afcd3e4", - "sha256:fc9bc7a9b79fe5c750fc81a307052f8daabb709bdaabb0fb18fb136b66b653b5" - ], - "index": "pypi", - "version": "==3.15.0" - }, - "pydantic": { - "hashes": [ - "sha256:02eefd7087268b711a3ff4db528e9916ac9aa18616da7bca69c1871d0b7a091f", - "sha256:059b6c1795170809103a1538255883e1983e5b831faea6558ef873d4955b4a74", - "sha256:0bf07cab5b279859c253d26a9194a8906e6f4a210063b84b433cf90a569de0c1", - "sha256:1542636a39c4892c4f4fa6270696902acb186a9aaeac6f6cf92ce6ae2e88564b", - "sha256:177071dfc0df6248fd22b43036f936cfe2508077a72af0933d0c1fa269b18537", - "sha256:18f3e912f9ad1bdec27fb06b8198a2ccc32f201e24174cec1b3424dda605a310", - "sha256:1dd8fecbad028cd89d04a46688d2fcc14423e8a196d5b0a5c65105664901f810", - "sha256:1ed987c3ff29fff7fd8c3ea3a3ea877ad310aae2ef9889a119e22d3f2db0691a", - "sha256:447d5521575f18e18240906beadc58551e97ec98142266e521c34968c76c8761", - "sha256:494f7c8537f0c02b740c229af4cb47c0d39840b829ecdcfc93d91dcbb0779892", - "sha256:4988c0f13c42bfa9ddd2fe2f569c9d54646ce84adc5de84228cfe83396f3bd58", - "sha256:4ce9ae9e91f46c344bec3b03d6ee9612802682c1551aaf627ad24045ce090761", - "sha256:5d93d4e95eacd313d2c765ebe40d49ca9dd2ed90e5b37d0d421c597af830c195", - "sha256:61b6760b08b7c395975d893e0b814a11cf011ebb24f7d869e7118f5a339a82e1", - "sha256:72ccb318bf0c9ab97fc04c10c37683d9eea952ed526707fabf9ac5ae59b701fd", - "sha256:79b485767c13788ee314669008d01f9ef3bc05db9ea3298f6a50d3ef596a154b", - "sha256:7eb57ba90929bac0b6cc2af2373893d80ac559adda6933e562dcfb375029acee", - "sha256:8bc541a405423ce0e51c19f637050acdbdf8feca34150e0d17f675e72d119580", - "sha256:969dd06110cb780da01336b281f53e2e7eb3a482831df441fb65dd30403f4608", - "sha256:985ceb5d0a86fcaa61e45781e567a59baa0da292d5ed2e490d612d0de5796918", - "sha256:9bcf8b6e011be08fb729d110f3e22e654a50f8a826b0575c7196616780683380", - "sha256:9ce157d979f742a915b75f792dbd6aa63b8eccaf46a1005ba03aa8a986bde34a", - "sha256:9f659a5ee95c8baa2436d392267988fd0f43eb774e5eb8739252e5a7e9cf07e0", - "sha256:a4a88dcd6ff8fd47c18b3a3709a89adb39a6373f4482e04c1b765045c7e282fd", - "sha256:a955260d47f03df08acf45689bd163ed9df82c0e0124beb4251b1290fa7ae728", - "sha256:a9af62e9b5b9bc67b2a195ebc2c2662fdf498a822d62f902bf27cccb52dbbf49", - "sha256:ae72f8098acb368d877b210ebe02ba12585e77bd0db78ac04a1ee9b9f5dd2166", - "sha256:b83ba3825bc91dfa989d4eed76865e71aea3a6ca1388b59fc801ee04c4d8d0d6", - "sha256:c11951b404e08b01b151222a1cb1a9f0a860a8153ce8334149ab9199cd198131", - "sha256:c320c64dd876e45254bdd350f0179da737463eea41c43bacbee9d8c9d1021f11", - "sha256:c8098a724c2784bf03e8070993f6d46aa2eeca031f8d8a048dff277703e6e193", - "sha256:d12f96b5b64bec3f43c8e82b4aab7599d0157f11c798c9f9c528a72b9e0b339a", - "sha256:e565a785233c2d03724c4dc55464559639b1ba9ecf091288dd47ad9c629433bd", - "sha256:f0f047e11febe5c3198ed346b507e1d010330d56ad615a7e0a89fae604065a0e", - "sha256:fe4670cb32ea98ffbf5a1262f14c3e102cccd92b1869df3bb09538158ba90fe6" - ], - "version": "==1.9.1" - }, - "pyngrok": { - "hashes": [ - "sha256:4d03f44a69c3cbc168b17377956a9edcf723e77dbc864eba34c272db15da443c" - ], - "index": "pypi", - "version": "==5.1.0" - }, - "pyparsing": { - "hashes": [ - "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", - "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc" - ], - "version": "==3.0.9" - }, - "pypng": { - "hashes": [ - "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c", - "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1" - ], - "index": "pypi", - "version": "==0.20220715.0" - }, - "pyqrcode": { - "hashes": [ - "sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6", - "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5" - ], - "index": "pypi", - "version": "==1.2.1" - }, - "pyscss": { - "hashes": [ - "sha256:8f35521ffe36afa8b34c7d6f3195088a7057c185c2b8f15ee459ab19748669ff" - ], - "index": "pypi", - "version": "==1.4.0" - }, - "python-dotenv": { - "hashes": [ - "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f", - "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938" - ], - "version": "==0.20.0" - }, - "pyyaml": { - "hashes": [ - "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", - "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", - "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", - "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", - "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", - "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", - "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", - "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", - "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", - "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", - "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", - "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", - "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", - "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", - "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", - "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", - "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", - "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", - "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", - "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", - "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", - "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", - "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", - "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", - "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", - "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", - "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", - "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", - "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", - "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", - "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", - "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", - "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" - ], - "version": "==6.0" - }, - "represent": { - "hashes": [ - "sha256:026c0de2ee8385d1255b9c2426cd4f03fe9177ac94c09979bc601946c8493aa0", - "sha256:99142650756ef1998ce0661568f54a47dac8c638fb27e3816c02536575dbba8c" - ], - "version": "==1.6.0.post0" - }, - "rfc3986": { - "hashes": [ - "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", - "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" - ], - "version": "==1.5.0" - }, - "secp256k1": { - "hashes": [ - "sha256:130f119b06142e597c10eb4470b5a38eae865362d01aaef06b113478d77f728d", - "sha256:373dc8bca735f3c2d73259aa2711a9ecea2f3c7edbb663555fe3422e3dd76102", - "sha256:3aedcfe6eb1c5fa7c6be25b7cc91c76d8eb984271920ba0f7a934ae41ed56f51", - "sha256:4b1bf09953cde181132cf5e9033065615e5c2694e803165e2db763efa47695e5", - "sha256:63eb148196b8f646922d4be6739b17fbbf50ebb3a020078c823e2445d88b7a81", - "sha256:6af07be5f8612628c3638dc7b208f6cc78d0abae3e25797eadb13890c7d5da81", - "sha256:72735da6cb28273e924431cd40aa607e7f80ef09608c8c9300be2e0e1d2417b4", - "sha256:7a27c479ab60571502516a1506a562d0a9df062de8ad645313fabfcc97252816", - "sha256:82c06712d69ef945220c8b53c1a0d424c2ff6a1f64aee609030df79ad8383397", - "sha256:87f4ad42a370f768910585989a301d1d65de17dcd86f6e8def9b021364b34d5c", - "sha256:97a30c8dae633cb18135c76b6517ae99dc59106818e8985be70dbc05dcc06c0d", - "sha256:a8dbd75a9fb6f42de307f3c5e24573fe59c3374637cbf39136edc66c200a4029", - "sha256:adc23a4c5d24c95191638eb2ca313097827f07db102e77b59faed15d50c98cae", - "sha256:bc761894b3634021686714278fc62b73395fa3eded33453eadfd8a00a6c44ef3", - "sha256:c91dd3154f6c46ac798d9a41166120e1751222587f54516cc3f378f56ce4ac82", - "sha256:c9e7c024ff17e9b9d7c392bb2a917da231d6cb40ab119389ff1f51dca10339a4", - "sha256:ce0314788d3248b275426501228969fd32f6501c9d1837902ee0e7bd8264a36f", - "sha256:f4062d8c101aa63b9ecb3709f1f075ad9c01b6672869bbaa1bd77271816936a7", - "sha256:f4b9306bff6dde020444dfee9ca9b9f5b20ca53a2c0b04898361a3f43d5daf2e", - "sha256:f666c67dcf1dc69e1448b2ede5e12aaf382b600204a61dbc65e4f82cea444405", - "sha256:fcabb3c3497a902fb61eec72d1b69bf72747d7bcc2a732d56d9319a1e8322262", - "sha256:fe3f503c9dfdf663b500d3e0688ad842e116c2907ad3f1e1d685812df3f56290", - "sha256:fec790cb6d0d37129ca0ce5b3f8e85692d5fb618d1c440f189453d18694035df" - ], - "index": "pypi", - "version": "==0.14.0" - }, - "shortuuid": { - "hashes": [ - "sha256:459f12fa1acc34ff213b1371467c0325169645a31ed989e268872339af7563d5", - "sha256:b2bb9eb7773170e253bb7ba25971023acb473517a8b76803d9618668cb1dd46f" - ], - "index": "pypi", - "version": "==1.0.9" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "version": "==1.16.0" - }, - "sniffio": { - "hashes": [ - "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", - "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" - ], - "version": "==1.2.0" - }, - "sqlalchemy": { - "hashes": [ - "sha256:040bdfc1d76a9074717a3f43455685f781c581f94472b010cd6c4754754e1862", - "sha256:1fe5d8d39118c2b018c215c37b73fd6893c3e1d4895be745ca8ff6eb83333ed3", - "sha256:23927c3981d1ec6b4ea71eb99d28424b874d9c696a21e5fbd9fa322718be3708", - "sha256:24f9569e82a009a09ce2d263559acb3466eba2617203170e4a0af91e75b4f075", - "sha256:2578dbdbe4dbb0e5126fb37ffcd9793a25dcad769a95f171a2161030bea850ff", - "sha256:269990b3ab53cb035d662dcde51df0943c1417bdab707dc4a7e4114a710504b4", - "sha256:29cccc9606750fe10c5d0e8bd847f17a97f3850b8682aef1f56f5d5e1a5a64b1", - "sha256:37b83bf81b4b85dda273aaaed5f35ea20ad80606f672d94d2218afc565fb0173", - "sha256:63677d0c08524af4c5893c18dbe42141de7178001360b3de0b86217502ed3601", - "sha256:639940bbe1108ac667dcffc79925db2966826c270112e9159439ab6bb14f8d80", - "sha256:6a939a868fdaa4b504e8b9d4a61f21aac11e3fecc8a8214455e144939e3d2aea", - "sha256:6b8b8c80c7f384f06825612dd078e4a31f0185e8f1f6b8c19e188ff246334205", - "sha256:6c9e6cc9237de5660bcddea63f332428bb83c8e2015c26777281f7ffbd2efb84", - "sha256:6ec1044908414013ebfe363450c22f14698803ce97fbb47e53284d55c5165848", - "sha256:6fca33672578666f657c131552c4ef8979c1606e494f78cd5199742dfb26918b", - "sha256:751934967f5336a3e26fc5993ccad1e4fee982029f9317eb6153bc0bc3d2d2da", - "sha256:8be835aac18ec85351385e17b8665bd4d63083a7160a017bef3d640e8e65cadb", - "sha256:927ce09e49bff3104459e1451ce82983b0a3062437a07d883a4c66f0b344c9b5", - "sha256:94208867f34e60f54a33a37f1c117251be91a47e3bfdb9ab8a7847f20886ad06", - "sha256:94f667d86be82dd4cb17d08de0c3622e77ca865320e0b95eae6153faa7b4ecaf", - "sha256:9e9c25522933e569e8b53ccc644dc993cab87e922fb7e142894653880fdd419d", - "sha256:a0e306e9bb76fd93b29ae3a5155298e4c1b504c7cbc620c09c20858d32d16234", - "sha256:a8bfc1e1afe523e94974132d7230b82ca7fa2511aedde1f537ec54db0399541a", - "sha256:ac2244e64485c3778f012951fdc869969a736cd61375fde6096d08850d8be729", - "sha256:b4b0e44d586cd64b65b507fa116a3814a1a53d55dce4836d7c1a6eb2823ff8d1", - "sha256:baeb451ee23e264de3f577fee5283c73d9bbaa8cb921d0305c0bbf700094b65b", - "sha256:c7dc052432cd5d060d7437e217dd33c97025287f99a69a50e2dc1478dd610d64", - "sha256:d1a85dfc5dee741bf49cb9b6b6b8d2725a268e4992507cf151cba26b17d97c37", - "sha256:d90010304abb4102123d10cbad2cdf2c25a9f2e66a50974199b24b468509bad5", - "sha256:ddfb511e76d016c3a160910642d57f4587dc542ce5ee823b0d415134790eeeb9", - "sha256:e273367f4076bd7b9a8dc2e771978ef2bfd6b82526e80775a7db52bff8ca01dd", - "sha256:e5bb3463df697279e5459a7316ad5a60b04b0107f9392e88674d0ece70e9cf70", - "sha256:e8a1750b44ad6422ace82bf3466638f1aa0862dbb9689690d5f2f48cce3476c8", - "sha256:eab063a70cca4a587c28824e18be41d8ecc4457f8f15b2933584c6c6cccd30f0", - "sha256:ecce8c021894a77d89808222b1ff9687ad84db54d18e4bd0500ca766737faaf6", - "sha256:f4d972139d5000105fcda9539a76452039434013570d6059993120dc2a65e447", - "sha256:fd3b96f8c705af8e938eaa99cbd8fd1450f632d38cad55e7367c33b263bf98ec", - "sha256:fdd2ed7395df8ac2dbb10cefc44737b66c6a5cd7755c92524733d7a443e5b7e2" - ], - "index": "pypi", - "version": "==1.3.23" - }, - "sqlalchemy-aio": { - "hashes": [ - "sha256:3f4aa392c38f032d6734826a4138a0f02ed3122d442ed142be1e5964f2a33b60", - "sha256:f531c7982662d71dfc0b117e77bb2ed544e25cd5361e76cf9f5208edcfb71f7b" - ], - "index": "pypi", - "version": "==0.17.0" - }, - "sse-starlette": { - "hashes": [ - "sha256:840607fed361360cc76f8408a25f0eca887e7cab3c3ee44f9762f179428e2bd4", - "sha256:ca2de945af80b83f1efda6144df9e13db83880b3b87c660044b64f199395e8b7" - ], - "index": "pypi", - "version": "==0.10.3" - }, - "starlette": { - "hashes": [ - "sha256:5a60c5c2d051f3a8eb546136aa0c9399773a689595e099e0877704d5888279bf", - "sha256:c6d21096774ecb9639acad41b86b7706e52ba3bf1dc13ea4ed9ad593d47e24c7" - ], - "version": "==0.19.1" - }, - "typing-extensions": { - "hashes": [ - "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02", - "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6" - ], - "index": "pypi", - "version": "==4.3.0" - }, - "uvicorn": { - "hashes": [ - "sha256:c19a057deb1c5bb060946e2e5c262fc01590c6529c0af2c3d9ce941e89bc30e0", - "sha256:cade07c403c397f9fe275492a48c1b869efd175d5d8a692df649e6e7e2ed8f4e" - ], - "index": "pypi", - "version": "==0.18.2" - }, - "uvloop": { - "hashes": [ - "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450", - "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897", - "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861", - "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c", - "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805", - "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d", - "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464", - "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f", - "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9", - "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab", - "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f", - "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638", - "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64", - "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee", - "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382", - "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228" - ], - "version": "==0.16.0" - }, - "watchfiles": { - "hashes": [ - "sha256:059bd9596429f8c13604b2eb30888a5661b3c79099edc506f11b63be7afe3ca4", - "sha256:09490d258be8fdd7f5141a39b468dede0b4aa4a52f2b2dbfb0f3835ae7c23eca", - "sha256:1bb5f0117c8b93f8e1b22ac0be60cfeb00332959a72e6bbe2073fea27ed086e5", - "sha256:3d3f0397c9128971398a5cbb0fb45852ab2fa4472ac9724c031071e1e39970c0", - "sha256:43d1d517faffa8955c2da0e6f64268e38442d43b50ca73cb686df25f891e49a1", - "sha256:4f712dbe9d8c0365bf46ffe0dd9c6a62cc0acf05ba951f1a53de2b4d5bb63299", - "sha256:59498853d3214d1e4d9b1cb3a06b0011a11f24d31708b1734d9cd7f5a30fe1af", - "sha256:5e3d4c92091d16bca1d61920575dab5d6dcbceda76dccd5fb91da0b7390b4ee9", - "sha256:5fa786d102e7eabef22b2147af531aa70194aabcb35335be81c07c26382b0050", - "sha256:750e40db5efcf3f5f11602dbc6fdf8e96a0eefdbccd271093efe9fa2e9d02ed2", - "sha256:7c80e3907d21ca3f1689f42632d239fdc40ffc1d5f32f564997480f85e94c474", - "sha256:8d635dcba3aab2909bf568765547696d7465d30e2e9c6f5ab99da877b58d29bb", - "sha256:a5f64674559fac56a6bf2f5e086cb3758740140c80711fe3e016f5443b84ef15", - "sha256:bcd085980389bc64fe509188a9caffa4fe13b2616e2e3e674cde58f916b2a8ee", - "sha256:c9e3756cd2ba17e5042e8c9399a08e4bdbe1a366156a164e8373bda30ca096d0", - "sha256:cbdb7814ca43f85ab8569206ab2c3bcd51dd5d1ba582914246784414e6ada62e", - "sha256:d5fb4f3b5c884d4f22f643b0697edbb04942bcad961a8f9a9bfadb73e7a1e229" - ], - "version": "==0.16.0" - }, - "websockets": { - "hashes": [ - "sha256:07cdc0a5b2549bcfbadb585ad8471ebdc7bdf91e32e34ae3889001c1c106a6af", - "sha256:210aad7fdd381c52e58777560860c7e6110b6174488ef1d4b681c08b68bf7f8c", - "sha256:28dd20b938a57c3124028680dc1600c197294da5db4292c76a0b48efb3ed7f76", - "sha256:2f94fa3ae454a63ea3a19f73b95deeebc9f02ba2d5617ca16f0bbdae375cda47", - "sha256:31564a67c3e4005f27815634343df688b25705cccb22bc1db621c781ddc64c69", - "sha256:347974105bbd4ea068106ec65e8e8ebd86f28c19e529d115d89bd8cc5cda3079", - "sha256:379e03422178436af4f3abe0aa8f401aa77ae2487843738542a75faf44a31f0c", - "sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55", - "sha256:51695d3b199cd03098ae5b42833006a0f43dc5418d3102972addc593a783bc02", - "sha256:54c000abeaff6d8771a4e2cef40900919908ea7b6b6a30eae72752607c6db559", - "sha256:5b936bf552e4f6357f5727579072ff1e1324717902127ffe60c92d29b67b7be3", - "sha256:6075fd24df23133c1b078e08a9b04a3bc40b31a8def4ee0b9f2c8865acce913e", - "sha256:661f641b44ed315556a2fa630239adfd77bd1b11cb0b9d96ed8ad90b0b1e4978", - "sha256:6ea6b300a6bdd782e49922d690e11c3669828fe36fc2471408c58b93b5535a98", - "sha256:6ed1d6f791eabfd9808afea1e068f5e59418e55721db8b7f3bfc39dc831c42ae", - "sha256:7934e055fd5cd9dee60f11d16c8d79c4567315824bacb1246d0208a47eca9755", - "sha256:7ab36e17af592eec5747c68ef2722a74c1a4a70f3772bc661079baf4ae30e40d", - "sha256:7f6d96fdb0975044fdd7953b35d003b03f9e2bcf85f2d2cf86285ece53e9f991", - "sha256:83e5ca0d5b743cde3d29fda74ccab37bdd0911f25bd4cdf09ff8b51b7b4f2fa1", - "sha256:85506b3328a9e083cc0a0fb3ba27e33c8db78341b3eb12eb72e8afd166c36680", - "sha256:8af75085b4bc0b5c40c4a3c0e113fa95e84c60f4ed6786cbb675aeb1ee128247", - "sha256:8b1359aba0ff810d5830d5ab8e2c4a02bebf98a60aa0124fb29aa78cfdb8031f", - "sha256:8fbd7d77f8aba46d43245e86dd91a8970eac4fb74c473f8e30e9c07581f852b2", - "sha256:907e8247480f287aa9bbc9391bd6de23c906d48af54c8c421df84655eef66af7", - "sha256:93d5ea0b5da8d66d868b32c614d2b52d14304444e39e13a59566d4acb8d6e2e4", - "sha256:97bc9d41e69a7521a358f9b8e44871f6cdeb42af31815c17aed36372d4eec667", - "sha256:994cdb1942a7a4c2e10098d9162948c9e7b235df755de91ca33f6e0481366fdb", - "sha256:a141de3d5a92188234afa61653ed0bbd2dde46ad47b15c3042ffb89548e77094", - "sha256:a1e15b230c3613e8ea82c9fc6941b2093e8eb939dd794c02754d33980ba81e36", - "sha256:aad5e300ab32036eb3fdc350ad30877210e2f51bceaca83fb7fef4d2b6c72b79", - "sha256:b529fdfa881b69fe563dbd98acce84f3e5a67df13de415e143ef053ff006d500", - "sha256:b9c77f0d1436ea4b4dc089ed8335fa141e6a251a92f75f675056dac4ab47a71e", - "sha256:bb621ec2dbbbe8df78a27dbd9dd7919f9b7d32a73fafcb4d9252fc4637343582", - "sha256:c7250848ce69559756ad0086a37b82c986cd33c2d344ab87fea596c5ac6d9442", - "sha256:c8d1d14aa0f600b5be363077b621b1b4d1eb3fbf90af83f9281cda668e6ff7fd", - "sha256:d1655a6fc7aecd333b079d00fb3c8132d18988e47f19740c69303bf02e9883c6", - "sha256:d6353ba89cfc657a3f5beabb3b69be226adbb5c6c7a66398e17809b0ce3c4731", - "sha256:da4377904a3379f0c1b75a965fff23b28315bcd516d27f99a803720dfebd94d4", - "sha256:e49ea4c1a9543d2bd8a747ff24411509c29e4bdcde05b5b0895e2120cb1a761d", - "sha256:e4e08305bfd76ba8edab08dcc6496f40674f44eb9d5e23153efa0a35750337e8", - "sha256:e6fa05a680e35d0fcc1470cb070b10e6fe247af54768f488ed93542e71339d6f", - "sha256:e7e6f2d6fd48422071cc8a6f8542016f350b79cc782752de531577d35e9bd677", - "sha256:e904c0381c014b914136c492c8fa711ca4cced4e9b3d110e5e7d436d0fc289e8", - "sha256:ec2b0ab7edc8cd4b0eb428b38ed89079bdc20c6bdb5f889d353011038caac2f9", - "sha256:ef5ce841e102278c1c2e98f043db99d6755b1c58bde475516aef3a008ed7f28e", - "sha256:f351c7d7d92f67c0609329ab2735eee0426a03022771b00102816a72715bb00b", - "sha256:fab7c640815812ed5f10fbee7abbf58788d602046b7bb3af9b1ac753a6d5e916", - "sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4" - ], - "version": "==10.3" - } - }, - "develop": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "version": "==1.4.4" - }, - "attrs": { - "hashes": [ - "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", - "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" - ], - "version": "==21.4.0" - }, - "black": { - "hashes": [ - "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea" - ], - "index": "pypi", - "version": "==20.8b1" - }, - "certifi": { - "hashes": [ - "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", - "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" - ], - "version": "==2022.6.15" - }, - "charset-normalizer": { - "hashes": [ - "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5", - "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413" - ], - "version": "==2.1.0" - }, - "click": { - "hashes": [ - "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", - "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" - ], - "version": "==8.1.3" - }, - "coverage": { - "hashes": [ - "sha256:0895ea6e6f7f9939166cc835df8fa4599e2d9b759b02d1521b574e13b859ac32", - "sha256:0f211df2cba951ffcae210ee00e54921ab42e2b64e0bf2c0befc977377fb09b7", - "sha256:147605e1702d996279bb3cc3b164f408698850011210d133a2cb96a73a2f7996", - "sha256:24b04d305ea172ccb21bee5bacd559383cba2c6fcdef85b7701cf2de4188aa55", - "sha256:25b7ec944f114f70803d6529394b64f8749e93cbfac0fe6c5ea1b7e6c14e8a46", - "sha256:2b20286c2b726f94e766e86a3fddb7b7e37af5d0c635bdfa7e4399bc523563de", - "sha256:2dff52b3e7f76ada36f82124703f4953186d9029d00d6287f17c68a75e2e6039", - "sha256:2f8553878a24b00d5ab04b7a92a2af50409247ca5c4b7a2bf4eabe94ed20d3ee", - "sha256:3def6791adf580d66f025223078dc84c64696a26f174131059ce8e91452584e1", - "sha256:422fa44070b42fef9fb8dabd5af03861708cdd6deb69463adc2130b7bf81332f", - "sha256:4f89d8e03c8a3757aae65570d14033e8edf192ee9298303db15955cadcff0c63", - "sha256:5336e0352c0b12c7e72727d50ff02557005f79a0b8dcad9219c7c4940a930083", - "sha256:54d8d0e073a7f238f0666d3c7c0d37469b2aa43311e4024c925ee14f5d5a1cbe", - "sha256:5ef42e1db047ca42827a85e34abe973971c635f83aed49611b7f3ab49d0130f0", - "sha256:5f65e5d3ff2d895dab76b1faca4586b970a99b5d4b24e9aafffc0ce94a6022d6", - "sha256:6c3ccfe89c36f3e5b9837b9ee507472310164f352c9fe332120b764c9d60adbe", - "sha256:6d0b48aff8e9720bdec315d67723f0babd936a7211dc5df453ddf76f89c59933", - "sha256:6fe75dcfcb889b6800f072f2af5a331342d63d0c1b3d2bf0f7b4f6c353e8c9c0", - "sha256:79419370d6a637cb18553ecb25228893966bd7935a9120fa454e7076f13b627c", - "sha256:7bb00521ab4f99fdce2d5c05a91bddc0280f0afaee0e0a00425e28e209d4af07", - "sha256:80db4a47a199c4563d4a25919ff29c97c87569130375beca3483b41ad5f698e8", - "sha256:866ebf42b4c5dbafd64455b0a1cd5aa7b4837a894809413b930026c91e18090b", - "sha256:8af6c26ba8df6338e57bedbf916d76bdae6308e57fc8f14397f03b5da8622b4e", - "sha256:a13772c19619118903d65a91f1d5fea84be494d12fd406d06c849b00d31bf120", - "sha256:a697977157adc052284a7160569b36a8bbec09db3c3220642e6323b47cec090f", - "sha256:a9032f9b7d38bdf882ac9f66ebde3afb8145f0d4c24b2e600bc4c6304aafb87e", - "sha256:b5e28db9199dd3833cc8a07fa6cf429a01227b5d429facb56eccd765050c26cd", - "sha256:c77943ef768276b61c96a3eb854eba55633c7a3fddf0a79f82805f232326d33f", - "sha256:d230d333b0be8042ac34808ad722eabba30036232e7a6fb3e317c49f61c93386", - "sha256:d4548be38a1c810d79e097a38107b6bf2ff42151900e47d49635be69943763d8", - "sha256:d4e7ced84a11c10160c0697a6cc0b214a5d7ab21dfec1cd46e89fbf77cc66fae", - "sha256:d56f105592188ce7a797b2bd94b4a8cb2e36d5d9b0d8a1d2060ff2a71e6b9bbc", - "sha256:d714af0bdba67739598849c9f18efdcc5a0412f4993914a0ec5ce0f1e864d783", - "sha256:d774d9e97007b018a651eadc1b3970ed20237395527e22cbeb743d8e73e0563d", - "sha256:e0524adb49c716ca763dbc1d27bedce36b14f33e6b8af6dba56886476b42957c", - "sha256:e2618cb2cf5a7cc8d698306e42ebcacd02fb7ef8cfc18485c59394152c70be97", - "sha256:e36750fbbc422c1c46c9d13b937ab437138b998fe74a635ec88989afb57a3978", - "sha256:edfdabe7aa4f97ed2b9dd5dde52d2bb29cb466993bb9d612ddd10d0085a683cf", - "sha256:f22325010d8824594820d6ce84fa830838f581a7fd86a9235f0d2ed6deb61e29", - "sha256:f23876b018dfa5d3e98e96f5644b109090f16a4acb22064e0f06933663005d39", - "sha256:f7bd0ffbcd03dc39490a1f40b2669cc414fae0c4e16b77bb26806a4d0b7d1452" - ], - "version": "==6.4.2" - }, - "idna": { - "hashes": [ - "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", - "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" - ], - "version": "==3.3" - }, - "iniconfig": { - "hashes": [ - "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", - "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" - ], - "version": "==1.1.1" - }, - "mock": { - "hashes": [ - "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62", - "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc" - ], - "index": "pypi", - "version": "==4.0.3" - }, - "mypy": { - "hashes": [ - "sha256:02ef476f6dcb86e6f502ae39a16b93285fef97e7f1ff22932b657d1ef1f28655", - "sha256:0d054ef16b071149917085f51f89555a576e2618d5d9dd70bd6eea6410af3ac9", - "sha256:19830b7dba7d5356d3e26e2427a2ec91c994cd92d983142cbd025ebe81d69cf3", - "sha256:1f7656b69974a6933e987ee8ffb951d836272d6c0f81d727f1d0e2696074d9e6", - "sha256:23488a14a83bca6e54402c2e6435467a4138785df93ec85aeff64c6170077fb0", - "sha256:23c7ff43fff4b0df93a186581885c8512bc50fc4d4910e0f838e35d6bb6b5e58", - "sha256:25c5750ba5609a0c7550b73a33deb314ecfb559c350bb050b655505e8aed4103", - "sha256:2ad53cf9c3adc43cf3bea0a7d01a2f2e86db9fe7596dfecb4496a5dda63cbb09", - "sha256:3fa7a477b9900be9b7dd4bab30a12759e5abe9586574ceb944bc29cddf8f0417", - "sha256:40b0f21484238269ae6a57200c807d80debc6459d444c0489a102d7c6a75fa56", - "sha256:4b21e5b1a70dfb972490035128f305c39bc4bc253f34e96a4adf9127cf943eb2", - "sha256:5a361d92635ad4ada1b1b2d3630fc2f53f2127d51cf2def9db83cba32e47c856", - "sha256:77a514ea15d3007d33a9e2157b0ba9c267496acf12a7f2b9b9f8446337aac5b0", - "sha256:855048b6feb6dfe09d3353466004490b1872887150c5bb5caad7838b57328cc8", - "sha256:9796a2ba7b4b538649caa5cecd398d873f4022ed2333ffde58eaf604c4d2cb27", - "sha256:98e02d56ebe93981c41211c05adb630d1d26c14195d04d95e49cd97dbc046dc5", - "sha256:b793b899f7cf563b1e7044a5c97361196b938e92f0a4343a5d27966a53d2ec71", - "sha256:d1ea5d12c8e2d266b5fb8c7a5d2e9c0219fedfeb493b7ed60cd350322384ac27", - "sha256:d2022bfadb7a5c2ef410d6a7c9763188afdb7f3533f22a0a32be10d571ee4bbe", - "sha256:d3348e7eb2eea2472db611486846742d5d52d1290576de99d59edeb7cd4a42ca", - "sha256:d744f72eb39f69312bc6c2abf8ff6656973120e2eb3f3ec4f758ed47e414a4bf", - "sha256:ef943c72a786b0f8d90fd76e9b39ce81fb7171172daf84bf43eaf937e9f220a9", - "sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c" - ], - "index": "pypi", - "version": "==0.971" - }, - "mypy-extensions": { - "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" - ], - "version": "==0.4.3" - }, - "packaging": { - "hashes": [ - "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", - "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" - ], - "version": "==21.3" - }, - "pathspec": { - "hashes": [ - "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a", - "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1" - ], - "version": "==0.9.0" - }, - "pluggy": { - "hashes": [ - "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", - "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" - ], - "version": "==1.0.0" - }, - "py": { - "hashes": [ - "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", - "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" - ], - "version": "==1.11.0" - }, - "pyparsing": { - "hashes": [ - "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", - "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc" - ], - "version": "==3.0.9" - }, - "pytest": { - "hashes": [ - "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c", - "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45" - ], - "index": "pypi", - "version": "==7.1.2" - }, - "pytest-asyncio": { - "hashes": [ - "sha256:7a97e37cfe1ed296e2e84941384bdd37c376453912d397ed39293e0916f521fa", - "sha256:ac4ebf3b6207259750bc32f4c1d8fcd7e79739edbc67ad0c58dd150b1d072fed" - ], - "index": "pypi", - "version": "==0.19.0" - }, - "pytest-cov": { - "hashes": [ - "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6", - "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470" - ], - "index": "pypi", - "version": "==3.0.0" - }, - "regex": { - "hashes": [ - "sha256:00d2e907d3c5e4f85197c8d2263a9cc2d34bf234a9c6236ae42a3fb0bc09b759", - "sha256:0186edcda692c38381db8ac257c2d023fd2e08818d45dc5bee4ed84212045f51", - "sha256:06c509bd7dcb7966bdb03974457d548e54d8327bad5b0c917e87248edc43e2eb", - "sha256:0a3f3f45c5902eb4d90266002ccb035531ae9b9278f6d5e8028247c34d192099", - "sha256:0c1821146b429e6fdbd13ea10f26765e48d5284bc79749468cfbfe3ceb929f0d", - "sha256:0d93167b7d7731fa9ff9fdc1bae84ec9c7133b01a35f8cc04e926d48da6ce1f7", - "sha256:0fd8c3635fa03ef79d07c7b3ed693b3f3930ccb52c0c51761c3296a7525b135c", - "sha256:119091c675e6ad19da8770f89aa1d52f4ad2a2018d631956f3e90c45882df880", - "sha256:121981ba84309dabefd5e1debd49be6d51624e54b4d44bfc184cd8d555ff1df1", - "sha256:1244e9b9b4b81c9c34e8a84273ffaeebdc78abc98a5b02dcdd49845eb3c63bd7", - "sha256:12e1404dfb4e928d3273a10e3468877fe84bdcd3c50b655a2c9613cfc5d9fe63", - "sha256:13d74951c14708f00700bb29475129ecbc40e01b4029c62ee7bfe9d1f59f31ce", - "sha256:162a5939a6fdf48658d3565eeff35acdd207e07367bf5caaff3d9ea7cb77d7a9", - "sha256:1703490c5b850fa9cef1af00c58966756042e6ca22f4fb5bb857345cd535834f", - "sha256:18e6203cfd81df42a987175aaeed7ba46bcb42130cd81763e2d5edcff0006d5d", - "sha256:192c2784833aea6fc7b004730bf1b91b8b8c6b998b30271aaf3bd8adfef20a96", - "sha256:1948d3ceac5b2d55bc93159c1e0679a256a87a54c735be5cef4543a9e692dbb9", - "sha256:206a327e628bc529d64b21ff79a5e2564f5aec7dc7abcd4b2e8a4b271ec10550", - "sha256:2e5db20412f0db8798ff72473d16da5f13ec808e975b49188badb2462f529fa9", - "sha256:2f94b0befc811fe74a972b1739fffbf74c0dc1a91102aca8e324aa4f2c6991bd", - "sha256:303676797c4c7978726e74eb8255d68f7125a3a29da71ff453448f2117290e9a", - "sha256:34ae4f35db30caa4caf85c55069fcb7a05966a3a5ba6e9e1dab5477d84fbb08a", - "sha256:3c6df8be7d1dd35a0d9a200fbc29f888c4452c8882d284f87608046152e049e6", - "sha256:402fa998c5988d11ed34585eb65740dcebd0fd11844d12eb0a6b4be178eb9c64", - "sha256:40a28759d345c0bb1f5b0ac74ac04f5d48136019522c95c0ec4b07786f67ce20", - "sha256:414ae507ba88264444baf771fec43ce0adcd4c5dbb304d3e0716f3f4d4499d2e", - "sha256:42da079e31ae9818ffa7a35cdd16ab7104e3f7eca9c0958040aede827b2e55c6", - "sha256:473a7d21932ce7c314953b33c32e63df690181860edcdf14bba1278cdf71b07f", - "sha256:49fcb45931a693b0e901972c5e077ea2cf30ec39da699645c43cb8b1542c6e14", - "sha256:4c5913cb9769038bd03e42318955c2f15a688384a6a0b807bcfc8271603d9277", - "sha256:4cfeb71095c8d8380a5df5a38ff94d27a3f483717e509130a822b4d6400b7991", - "sha256:4dc74f0171eede67d79a79c06eca0fe5b7b280dbb8c27ad1fae4ced2ad66268f", - "sha256:5b1cffff2d9f832288fe516021cb81c95c57c0067b13a82f1d2daabdbc2f4270", - "sha256:601c99ac775b6c89699a48976f3dbb000b47d3ca59362c8abc9582e6d0780d91", - "sha256:667a06bb8d72b6da3d9cf38dac4ba969688868ed2279a692e993d2c0e1c30aba", - "sha256:673549a0136c7893f567ed71ab5225ed3701c79b17c0a7faee846c645fc24010", - "sha256:67bd3bdd27db7a6460384869dd4b9c54267d805b67d70b20495bb5767f8e051c", - "sha256:727edff0a4eaff3b6d26cbb50216feac9055aba7e6290eec23c061c2fe2fab55", - "sha256:782627a1cb8fbb1c78d8e841f5b71c2c683086c038f975bebdac7cce7678a96f", - "sha256:7d462ba84655abeddae4dfc517fe1afefb5430b3b5acb0a954de12a47aea7183", - "sha256:8ab39aa445d00902c43a1e951871bedc7f18d095a21eccba153d594faac34aea", - "sha256:8e2075ed4ea2e231e2e98b16cfa5dae87e9a6045a71104525e1efc29aa8faa8e", - "sha256:9daeccb2764bf4cc280c40c6411ae176bb0876948e536590a052b3d647254c95", - "sha256:9e4006942334fa954ebd32fa0728718ec870f95f4ba7cda9edc46dd49c294f22", - "sha256:9f1c8fffd4def0b76c0947b8cb261b266e31041785dc2dc2db7569407a2f54fe", - "sha256:a00cd58a30a1041c193777cb1bc090200b05ff4b073d5935738afd1023e63069", - "sha256:a0220a7a16fd4bfc700661f920510defd31ef7830ce992d5cc51777aa8ccd724", - "sha256:a048f91823862270905cb22ef88038b08aac852ce48e0ecc4b4bf1b895ec37d9", - "sha256:a3c47c71fde0c5d584402e67546c81af9951540f1f622d821e9c20761556473a", - "sha256:a6d9ea727fd1233ee746bf44dd37e7d4320b3ed8ff09e73d7638c969b28d280f", - "sha256:ab0709daedc1099bbd4371ae17eeedd4efc1cf70fcdcfe5de1374a0944b61f80", - "sha256:ab1cb36b411f16da6e057ef8e6657dd0af36f59a667f07e0b4b617e44e53d7b2", - "sha256:ae1c5b435d44aa91d48cc710f20c3485e0584a3ad3565d5ae031d61a35f674f4", - "sha256:b279b9bb401af41130fd2a09427105100bc8c624ed45da1c81c1c0d0aa639734", - "sha256:b72a4ec79a15f6066d14ae1c472b743af4b4ecee14420e8d6e4a336b49b8f21c", - "sha256:c2cd93725911c0159d597b90c96151070ef7e0e67604637e2f2abe06c34bf079", - "sha256:c7c5f914b0eb5242c09f91058b80295525897e873b522575ab235b48db125597", - "sha256:d07d849c9e2eca80adb85d3567302a47195a603ad7b1f0a07508e253c041f954", - "sha256:d2672d68cf6c8452b6758fc3cd2d8feac966d511eed79a68182a5297b473af9c", - "sha256:d35bbcbf70d14f724e7489746cf68efe122796578addd98f91428e144d0ad266", - "sha256:d40b4447784dbe0896a6d10a178f6724598161f942c56f5a60dc0ef7fe63f7a1", - "sha256:d561dcb0fb0ab858291837d51330696a45fd3ba6912a332a4ee130e5484b9e47", - "sha256:d7f5ccfff648093152cadf6d886c7bd922047532f72024c953a79c7553aac2fe", - "sha256:dce6b2ad817e3eb107f8704782b091b0631dd3adf47f14bdc086165d05b528b0", - "sha256:e1fdda3ec7e9785065b67941693995cab95b54023a21db9bf39e54cc7b2c3526", - "sha256:e2a262ec85c595fc8e1f3162cafc654d2219125c00ea3a190c173cea70d2cc7a", - "sha256:e2fc1e3928c1189c0382c547c17717c6d9f425fffe619ef94270fe4c6c8be0a6", - "sha256:ea27acd97a752cfefa9907da935e583efecb302e6e9866f37565968c8407ad58", - "sha256:ee769a438827e443ed428e66d0aa7131c653ecd86ddc5d4644a81ed1d93af0e7", - "sha256:f32e0d1c7e7b0b9c3cac76f3d278e7ee6b99c95672d2c1c6ea625033431837c0", - "sha256:f355caec5bbce20421dc26e53787b10e32fd0df68db2b795435217210c08d69c", - "sha256:f87e9108bb532f8a1fc6bf7e69b930a35c7b0267b8fef0a3ede0bcb4c5aaa531", - "sha256:f8a2fd2f62a77536e4e3193303bec380df40d99e253b1c8f9b6eafa07eaeff67", - "sha256:fbdf4fc6adf38fab1091c579ece3fe9f493bd0f1cfc3d2c76d2e52461ca4f8a9" - ], - "version": "==2022.7.9" - }, - "requests": { - "hashes": [ - "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", - "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" - ], - "index": "pypi", - "version": "==2.28.1" - }, - "toml": { - "hashes": [ - "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", - "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" - ], - "version": "==0.10.2" - }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "version": "==2.0.1" - }, - "typed-ast": { - "hashes": [ - "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2", - "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1", - "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6", - "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62", - "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac", - "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d", - "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc", - "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2", - "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97", - "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35", - "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6", - "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1", - "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4", - "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c", - "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e", - "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec", - "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f", - "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72", - "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47", - "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72", - "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe", - "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6", - "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3", - "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66" - ], - "version": "==1.5.4" - }, - "typing-extensions": { - "hashes": [ - "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02", - "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6" - ], - "index": "pypi", - "version": "==4.3.0" - }, - "urllib3": { - "hashes": [ - "sha256:8298d6d56d39be0e3bc13c1c97d133f9b45d797169a0e11cdd0e0489d786f7ec", - "sha256:879ba4d1e89654d9769ce13121e0f94310ea32e8d2f8cf587b77c08bbcdb30d6" - ], - "version": "==1.26.10" - } - } -} diff --git a/docs/devs/installation.md b/docs/devs/installation.md index f4d6b145d..b807ce34e 100644 --- a/docs/devs/installation.md +++ b/docs/devs/installation.md @@ -8,7 +8,7 @@ nav_order: 1 # Installation This guide has been moved to the [installation guide](../guide/installation.md). -To install the developer packages, use `pipenv install --dev`. +To install the developer packages for running tests etc before pr'ing, use `./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock black mypy isort`. ## Notes: diff --git a/docs/guide/installation.md b/docs/guide/installation.md index f38f606d1..0232346fb 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -8,7 +8,7 @@ nav_order: 2 # Basic installation -You can choose between four package managers, `poetry`, `pipenv`, `venv` and `nix`. +You can choose between four package managers, `poetry`, `nix` and `venv`. By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below). @@ -33,64 +33,13 @@ poetry run lnbits # To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0' ``` -## Option 2: pipenv +## Option 2: Nix ```sh git clone https://github.com/lnbits/lnbits-legend.git cd lnbits-legend/ - -sudo apt update && sudo apt install -y pipenv -pipenv install --dev -# pipenv --python 3.9 install --dev (if you wish to use a version of Python higher than 3.7) -pipenv shell -# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7) - -# If any of the modules fails to install, try checking and upgrading your setupTool module -# pip install -U setuptools wheel - -# install libffi/libpq in case "pipenv install" fails -# sudo apt-get install -y libffi-dev libpq-dev - - mkdir data && cp .env.example .env -``` - -#### Running the server - -```sh -pipenv run python -m uvicorn lnbits.__main__:app --port 5000 --host 0.0.0.0 -``` - -Add the flag `--reload` for development (includes hot-reload). - - -## Option 3: venv - -```sh -git clone https://github.com/lnbits/lnbits-legend.git -cd lnbits-legend/ -# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv' -python3 -m venv venv -# If you have problems here, try `sudo apt install -y pkg-config libpq-dev` -./venv/bin/pip install -r requirements.txt -# create the data folder and the .env file -mkdir data && cp .env.example .env -``` - -#### Running the server - -```sh -./venv/bin/uvicorn lnbits.__main__:app --port 5000 -``` - -If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`. - -## Option 4: Nix - -```sh -git clone https://github.com/lnbits/lnbits-legend.git -cd lnbits-legend/ -# Install nix, modern debian distros usually already include -sh <(curl -L https://nixos.org/nix/install) --daemon +# Modern debian distros usually include Nix, however you can install with: +# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation nix build .#lnbits mkdir data @@ -104,6 +53,29 @@ mkdir data LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000 ``` +## Option 3: venv + +```sh +git clone https://github.com/lnbits/lnbits-legend.git +cd lnbits-legend/ +# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv' +python3 -m venv venv +# If you have problems here, try `sudo apt install -y pkg-config libpq-dev` +./venv/bin/pip install -r requirements.txt +# create the data folder and the .env file +mkdir data && cp .env.example .env +# build the static files +./venv/bin/python build.py +``` + +#### Running the server + +```sh +./venv/bin/uvicorn lnbits.__main__:app --port 5000 +``` + +If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`. + ### Troubleshooting Problems installing? These commands have helped us install LNbits. @@ -112,10 +84,10 @@ Problems installing? These commands have helped us install LNbits. sudo apt install pkg-config libffi-dev libpq-dev # if the secp256k1 build fails: -# if you used pipenv (option 1) -pipenv install setuptools wheel -# if you used venv (option 2) +# if you used venv ./venv/bin/pip install setuptools wheel +# if you used poetry +poetry add setuptools wheel # build essentials for debian/ubuntu sudo apt install python3-dev gcc build-essential ``` diff --git a/lnbits/app.py b/lnbits/app.py index 19482b067..e1594a7b4 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -17,7 +17,6 @@ from loguru import logger import lnbits.settings from lnbits.core.tasks import register_task_listeners -from .commands import db_migrate, handle_assets from .core import core_app from .core.views.generic import core_html_routes from .helpers import ( @@ -93,7 +92,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI: check_funding_source(app) register_assets(app) register_routes(app) - # register_commands(app) register_async_tasks(app) register_exception_handlers(app) @@ -146,12 +144,6 @@ def register_routes(app: FastAPI) -> None: ) -def register_commands(app: FastAPI): - """Register Click commands.""" - app.cli.add_command(db_migrate) - app.cli.add_command(handle_assets) - - def register_assets(app: FastAPI): """Serve each vendored asset separately or a bundle.""" diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index cc8415852..670397409 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -61,7 +61,7 @@ def decode(pr: str) -> Invoice: invoice = Invoice() # decode the amount from the hrp - m = re.search("[^\d]+", hrp[2:]) + m = re.search(r"[^\d]+", hrp[2:]) if m: amountstr = hrp[2 + m.end() :] if amountstr != "": @@ -296,7 +296,7 @@ def _unshorten_amount(amount: str) -> int: # BOLT #11: # A reader SHOULD fail if `amount` contains a non-digit, or is followed by # anything except a `multiplier` in the table above. - if not re.fullmatch("\d+[pnum]?", str(amount)): + if not re.fullmatch(r"\d+[pnum]?", str(amount)): raise ValueError("Invalid amount '{}'".format(amount)) if unit in units: diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 770e2906a..db802d7bb 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -113,7 +113,7 @@ async def create_wallet( async def update_wallet( wallet_id: str, new_name: str, conn: Optional[Connection] = None ) -> Optional[Wallet]: - await (conn or db).execute( + return await (conn or db).execute( """ UPDATE wallets SET name = ? diff --git a/lnbits/core/models.py b/lnbits/core/models.py index ab73b7020..0f7eba737 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -106,6 +106,8 @@ class Payment(BaseModel): @property def tag(self) -> Optional[str]: + if self.extra is None: + return "" return self.extra.get("tag") @property diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 0b565ebb9..b9aec8997 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -109,18 +109,15 @@ async def pay_invoice( 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( + class PaymentKwargs(TypedDict): + wallet_id: str + payment_request: str + payment_hash: str + amount: int + memo: str + extra: Optional[Dict] + + payment_kwargs: PaymentKwargs = PaymentKwargs( wallet_id=wallet_id, payment_request=payment_request, payment_hash=invoice.payment_hash, @@ -272,6 +269,7 @@ async def perform_lnurlauth( cb = urlparse(callback) k1 = unhexlify(parse_qs(cb.query)["k1"][0]) + key = wallet.wallet.lnurlauth_key(cb.netloc) def int_to_bytes_suitable_der(x: int) -> bytes: diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index 5fea769d4..07b8a8933 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -55,7 +55,7 @@ async def dispatch_webhook(payment: Payment): data = payment.dict() try: logger.debug("sending webhook", payment.webhook) - r = await client.post(payment.webhook, json=data, timeout=40) + r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore await mark_webhook_sent(payment, r.status_code) except (httpx.ConnectError, httpx.RequestError): await mark_webhook_sent(payment, -1) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index bc3e759fa..e54e30fac 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -3,10 +3,12 @@ import hashlib import json from binascii import unhexlify from http import HTTPStatus -from typing import Dict, List, Optional, Union +from io import BytesIO +from typing import Dict, List, Optional, Tuple, Union from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse import httpx +import pyqrcode from fastapi import Depends, Header, Query, Request from fastapi.exceptions import HTTPException from fastapi.params import Body @@ -14,6 +16,7 @@ from loguru import logger from pydantic import BaseModel from pydantic.fields import Field from sse_starlette.sse import EventSourceResponse +from starlette.responses import HTMLResponse, StreamingResponse from lnbits import bolt11, lnurl from lnbits.core.models import Payment, Wallet @@ -185,7 +188,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): assert ( data.lnurl_balance_check is not None ), "lnurl_balance_check is required" - save_balance_check(wallet.id, data.lnurl_balance_check) + await save_balance_check(wallet.id, data.lnurl_balance_check) async with httpx.AsyncClient() as client: try: @@ -248,7 +251,7 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet): ) async def api_payments_create( wallet: WalletTypeInfo = Depends(require_invoice_key), - invoiceData: CreateInvoiceData = Body(...), + invoiceData: CreateInvoiceData = Body(...), # type: ignore ): if invoiceData.out is True and wallet.wallet_type == 0: if not invoiceData.bolt11: @@ -291,7 +294,7 @@ async def api_payments_pay_lnurl( timeout=40, ) if r.is_error: - raise httpx.ConnectError + raise httpx.ConnectError("LNURL callback connection error") except (httpx.ConnectError, httpx.RequestError): raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -354,7 +357,7 @@ async def subscribe(request: Request, wallet: Wallet): logger.debug("adding sse listener", payment_queue) api_invoice_listeners.append(payment_queue) - send_queue: asyncio.Queue[tuple[str, Payment]] = asyncio.Queue(0) + send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0) async def payment_received() -> None: while True: @@ -393,16 +396,13 @@ async def api_payments_sse( async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): # We use X_Api_Key here because we want this call to work with and without keys # If a valid key is given, we also return the field "details", otherwise not - wallet = None - try: - if X_Api_Key.extra: - logger.warning("No key") - except: - wallet = await get_wallet_for_key(X_Api_Key) + wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None + + # we have to specify the wallet id here, because postgres and sqlite return internal payments in different order + # and get_standalone_payment otherwise just fetches the first one, causing unpredictable results payment = await get_standalone_payment( payment_hash, wallet_id=wallet.id if wallet else None - ) # we have to specify the wallet id here, because postgres and sqlite return internal payments in different order - # and get_standalone_payment otherwise just fetches the first one, causing unpredictable results + ) if payment is None: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." @@ -488,7 +488,8 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type ) try: - tag = data["tag"] + tag: str = data.get("tag") + params.update(**data) if tag == "channelRequest": raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -498,10 +499,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type "message": "unsupported", }, ) - - params.update(**data) - - if tag == "withdrawRequest": + elif tag == "withdrawRequest": params.update(kind="withdraw") params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"]) @@ -519,8 +517,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type query=urlencode(qs, doseq=True) ) params.update(callback=urlunparse(parsed_callback)) - - if tag == "payRequest": + elif tag == "payRequest": params.update(kind="pay") params.update(fixed=data["minSendable"] == data["maxSendable"]) @@ -538,8 +535,8 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type params.update(image=data_uri) if k == "text/email" or k == "text/identifier": params.update(targetUser=v) - params.update(commentAllowed=data.get("commentAllowed", 0)) + except KeyError as exc: raise HTTPException( status_code=HTTPStatus.SERVICE_UNAVAILABLE, @@ -612,8 +609,8 @@ class ConversionData(BaseModel): async def api_fiat_as_sats(data: ConversionData): output = {} if data.from_ == "sat": - output["sats"] = int(data.amount) output["BTC"] = data.amount / 100000000 + output["sats"] = int(data.amount) for currency in data.to.split(","): output[currency.strip().upper()] = await satoshis_amount_as_fiat( data.amount, currency.strip() @@ -624,3 +621,24 @@ async def api_fiat_as_sats(data: ConversionData): output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_) output["BTC"] = output["sats"] / 100000000 return output + + +@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse) +async def img(request: Request, data): + qr = pyqrcode.create(data) + stream = BytesIO() + qr.svg(stream, scale=3) + stream.seek(0) + + async def _generator(stream: BytesIO): + yield stream.getvalue() + + return StreamingResponse( + _generator(stream), + headers={ + "Content-Type": "image/svg+xml", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 44666ce16..21572b285 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -55,9 +55,9 @@ async def home(request: Request, lightning: str = None): ) async def extensions( request: Request, - user: User = Depends(check_user_exists), - enable: str = Query(None), - disable: str = Query(None), + user: User = Depends(check_user_exists), # type: ignore + enable: str = Query(None), # type: ignore + disable: str = Query(None), # type: ignore ): extension_to_enable = enable extension_to_disable = disable @@ -88,7 +88,7 @@ async def extensions( # Update user as his extensions have been updated if extension_to_enable or extension_to_disable: - user = await get_user(user.id) + user = await get_user(user.id) # type: ignore return template_renderer().TemplateResponse( "core/extensions.html", {"request": request, "user": user.dict()} @@ -109,10 +109,10 @@ nothing: create everything
""", ) async def wallet( - request: Request = Query(None), - nme: Optional[str] = Query(None), - usr: Optional[UUID4] = Query(None), - wal: Optional[UUID4] = Query(None), + request: Request = Query(None), # type: ignore + nme: Optional[str] = Query(None), # type: ignore + usr: Optional[UUID4] = Query(None), # type: ignore + wal: Optional[UUID4] = Query(None), # type: ignore ): user_id = usr.hex if usr else None wallet_id = wal.hex if wal else None @@ -121,7 +121,7 @@ async def wallet( if not user_id: user = await get_user((await create_account()).id) - logger.info(f"Create user {user.id}") + logger.info(f"Create user {user.id}") # type: ignore else: user = await get_user(user_id) if not user: @@ -135,22 +135,22 @@ async def wallet( if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS: user.admin = True if not wallet_id: - if user.wallets and not wallet_name: - wallet = user.wallets[0] + if user.wallets and not wallet_name: # type: ignore + wallet = user.wallets[0] # type: ignore else: - wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) + wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore logger.info( - f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" + f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore ) return RedirectResponse( - f"/wallet?usr={user.id}&wal={wallet.id}", + f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore status_code=status.HTTP_307_TEMPORARY_REDIRECT, ) logger.debug(f"Access wallet {wallet_name}{'of user '+ user.id if user else ''}") - wallet = user.get_wallet(wallet_id) - if not wallet: + userwallet = user.get_wallet(wallet_id) # type: ignore + if not userwallet: return template_renderer().TemplateResponse( "error.html", {"request": request, "err": "Wallet not found"} ) @@ -159,10 +159,10 @@ async def wallet( "core/wallet.html", { "request": request, - "user": user.dict(), - "wallet": wallet.dict(), + "user": user.dict(), # type: ignore + "wallet": userwallet.dict(), "service_fee": service_fee, - "web_manifest": f"/manifest/{user.id}.webmanifest", + "web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore }, ) @@ -216,20 +216,20 @@ async def lnurl_full_withdraw_callback(request: Request): @core_html_routes.get("/deletewallet", response_class=RedirectResponse) -async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): +async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): # type: ignore user = await get_user(usr) - user_wallet_ids = [u.id for u in user.wallets] + user_wallet_ids = [u.id for u in user.wallets] # type: ignore if wal not in user_wallet_ids: raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.") else: - await delete_wallet(user_id=user.id, wallet_id=wal) + await delete_wallet(user_id=user.id, wallet_id=wal) # type: ignore user_wallet_ids.remove(wal) logger.debug("Deleted wallet {wal} of user {user.id}") if user_wallet_ids: return RedirectResponse( - url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), + url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), # type: ignore status_code=status.HTTP_307_TEMPORARY_REDIRECT, ) @@ -242,7 +242,7 @@ async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query async def lnurl_balance_notify(request: Request, service: str): bc = await get_balance_check(request.query_params.get("wal"), service) if bc: - redeem_lnurl_withdraw(bc.wallet, bc.url) + await redeem_lnurl_withdraw(bc.wallet, bc.url) @core_html_routes.get( @@ -252,7 +252,7 @@ async def lnurlwallet(request: Request): async with db.connect() as conn: account = await create_account(conn=conn) user = await get_user(account.id, conn=conn) - wallet = await create_wallet(user_id=user.id, conn=conn) + wallet = await create_wallet(user_id=user.id, conn=conn) # type: ignore asyncio.create_task( redeem_lnurl_withdraw( @@ -265,7 +265,7 @@ async def lnurlwallet(request: Request): ) return RedirectResponse( - f"/wallet?usr={user.id}&wal={wallet.id}", + f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore status_code=status.HTTP_307_TEMPORARY_REDIRECT, ) diff --git a/lnbits/decorators.py b/lnbits/decorators.py index e65b9041a..6685cfb2d 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -1,4 +1,5 @@ from http import HTTPStatus +from typing import Union from cerberus import Validator # type: ignore from fastapi import status @@ -29,20 +30,21 @@ class KeyChecker(SecurityBase): self._key_type = "invoice" self._api_key = api_key if api_key: - self.model: APIKey = APIKey( + key = APIKey( **{"in": APIKeyIn.query}, name="X-API-KEY", description="Wallet API Key - QUERY", ) else: - self.model: APIKey = APIKey( + key = APIKey( **{"in": APIKeyIn.header}, name="X-API-KEY", description="Wallet API Key - HEADER", ) - self.wallet = None + self.wallet = None # type: ignore + self.model: APIKey = key - async def __call__(self, request: Request) -> Wallet: + async def __call__(self, request: Request): try: key_value = ( self._api_key @@ -52,7 +54,7 @@ class KeyChecker(SecurityBase): # FIXME: Find another way to validate the key. A fetch from DB should be avoided here. # Also, we should not return the wallet here - thats silly. # Possibly store it in a Redis DB - self.wallet = await get_wallet_for_key(key_value, self._key_type) + self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore if not self.wallet: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, @@ -120,8 +122,8 @@ api_key_query = APIKeyQuery( async def get_key_type( r: Request, - api_key_header: str = Security(api_key_header), - api_key_query: str = Security(api_key_query), + api_key_header: str = Security(api_key_header), # type: ignore + api_key_query: str = Security(api_key_query), # type: ignore ) -> WalletTypeInfo: # 0: admin # 1: invoice @@ -134,9 +136,9 @@ async def get_key_type( token = api_key_header if api_key_header else api_key_query try: - checker = WalletAdminKeyChecker(api_key=token) - await checker.__call__(r) - wallet = WalletTypeInfo(0, checker.wallet) + admin_checker = WalletAdminKeyChecker(api_key=token) + await admin_checker.__call__(r) + wallet = WalletTypeInfo(0, admin_checker.wallet) # type: ignore if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and ( LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS ): @@ -153,9 +155,9 @@ async def get_key_type( raise try: - checker = WalletInvoiceKeyChecker(api_key=token) - await checker.__call__(r) - wallet = WalletTypeInfo(1, checker.wallet) + invoice_checker = WalletInvoiceKeyChecker(api_key=token) + await invoice_checker.__call__(r) + wallet = WalletTypeInfo(1, invoice_checker.wallet) # type: ignore if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and ( LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS ): @@ -167,15 +169,16 @@ async def get_key_type( if e.status_code == HTTPStatus.BAD_REQUEST: raise if e.status_code == HTTPStatus.UNAUTHORIZED: - return WalletTypeInfo(2, None) + return WalletTypeInfo(2, None) # type: ignore except: raise + return wallet async def require_admin_key( r: Request, - api_key_header: str = Security(api_key_header), - api_key_query: str = Security(api_key_query), + api_key_header: str = Security(api_key_header), # type: ignore + api_key_query: str = Security(api_key_query), # type: ignore ): token = api_key_header if api_key_header else api_key_query @@ -193,8 +196,8 @@ async def require_admin_key( async def require_invoice_key( r: Request, - api_key_header: str = Security(api_key_header), - api_key_query: str = Security(api_key_query), + api_key_header: str = Security(api_key_header), # type: ignore + api_key_query: str = Security(api_key_query), # type: ignore ): token = api_key_header if api_key_header else api_key_query diff --git a/lnbits/extensions/events/views_api.py b/lnbits/extensions/events/views_api.py index 45ee4de05..56e6b06ca 100644 --- a/lnbits/extensions/events/views_api.py +++ b/lnbits/extensions/events/views_api.py @@ -133,7 +133,8 @@ async def api_ticket_send_ticket(event_id, payment_hash, data: CreateTicket): if not ticket: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail=f"Event could not be fetched." + status_code=HTTPStatus.NOT_FOUND, + detail=f"Event could not be fetched.", ) return {"paid": True, "ticket_id": ticket.id} diff --git a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html index 4f921bb57..afe24c423 100644 --- a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html +++ b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html @@ -4,12 +4,7 @@ label="API info" :content-inset-level="0.5" > - + @@ -38,7 +33,6 @@ expand-separator label="Create a lnurlpayout" > - Optional[Sho async def add_item( - shop: int, name: str, description: str, image: Optional[str], price: int, unit: str + shop: int, + name: str, + description: str, + image: Optional[str], + price: int, + unit: str, + fiat_base_multiplier: int, ) -> int: result = await db.execute( """ - INSERT INTO offlineshop.items (shop, name, description, image, price, unit) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO offlineshop.items (shop, name, description, image, price, unit, fiat_base_multiplier) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (shop, name, description, image, price, unit), + (shop, name, description, image, price, unit, fiat_base_multiplier), ) return result._result_proxy.lastrowid @@ -72,6 +78,7 @@ async def update_item( image: Optional[str], price: int, unit: str, + fiat_base_multiplier: int, ) -> int: await db.execute( """ @@ -80,10 +87,11 @@ async def update_item( description = ?, image = ?, price = ?, - unit = ? + unit = ?, + fiat_base_multiplier = ? WHERE shop = ? AND id = ? """, - (name, description, image, price, unit, shop, item_id), + (name, description, image, price, unit, fiat_base_multiplier, shop, item_id), ) return item_id @@ -92,12 +100,12 @@ async def get_item(id: int) -> Optional[Item]: row = await db.fetchone( "SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,) ) - return Item(**dict(row)) if row else None + return Item.from_row(row) if row else None async def get_items(shop: int) -> List[Item]: rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,)) - return [Item(**dict(row)) for row in rows] + return [Item.from_row(row) for row in rows] async def delete_item_from_shop(shop: int, item_id: int): diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py index f7c2dfec8..84aea27e2 100644 --- a/lnbits/extensions/offlineshop/migrations.py +++ b/lnbits/extensions/offlineshop/migrations.py @@ -27,3 +27,13 @@ async def m001_initial(db): ); """ ) + + +async def m002_fiat_base_multiplier(db): + """ + Store the multiplier for fiat prices. We store the price in cents and + remember to multiply by 100 when we use it to convert to Dollars. + """ + await db.execute( + "ALTER TABLE offlineshop.items ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;" + ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py index 0128fdb84..ca5c73a55 100644 --- a/lnbits/extensions/offlineshop/models.py +++ b/lnbits/extensions/offlineshop/models.py @@ -2,6 +2,7 @@ import base64 import hashlib import json from collections import OrderedDict +from sqlite3 import Row from typing import Dict, List, Optional from lnurl import encode as lnurl_encode # type: ignore @@ -87,8 +88,16 @@ class Item(BaseModel): description: str image: Optional[str] enabled: bool - price: int + price: float unit: str + fiat_base_multiplier: int + + @classmethod + def from_row(cls, row: Row) -> "Item": + data = dict(row) + if data["unit"] != "sat" and data["fiat_base_multiplier"]: + data["price"] /= data["fiat_base_multiplier"] + return cls(**data) def lnurl(self, req: Request) -> str: return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id)) diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index 00e932416..c03906093 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -124,7 +124,8 @@ new Vue({ description, image, price, - unit + unit, + fiat_base_multiplier: unit == 'sat' ? 1 : 100 } try { diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index 5ced43516..1bd045dd9 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -1,6 +1,7 @@ from http import HTTPStatus from typing import Optional +from fastapi import Query from fastapi.params import Depends from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl from pydantic.main import BaseModel @@ -34,7 +35,6 @@ async def api_shop_from_wallet( ): shop = await get_or_create_shop_by_wallet(wallet.wallet.id) items = await get_items(shop.id) - try: return { **shop.dict(), @@ -51,8 +51,9 @@ class CreateItemsData(BaseModel): name: str description: str image: Optional[str] - price: int + price: float unit: str + fiat_base_multiplier: int = Query(100, ge=1) @offlineshop_ext.post("/api/v1/offlineshop/items") @@ -61,9 +62,18 @@ async def api_add_or_update_item( data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type) ): shop = await get_or_create_shop_by_wallet(wallet.wallet.id) + if data.unit != "sat": + data.price = data.price * 100 if item_id == None: + await add_item( - shop.id, data.name, data.description, data.image, data.price, data.unit + shop.id, + data.name, + data.description, + data.image, + data.price, + data.unit, + data.fiat_base_multiplier, ) return HTMLResponse(status_code=HTTPStatus.CREATED) else: @@ -75,6 +85,7 @@ async def api_add_or_update_item( data.image, data.price, data.unit, + data.fiat_base_multiplier, ) diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html index 5be38cf6b..551b81b8e 100644 --- a/lnbits/extensions/satspay/templates/satspay/index.html +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -225,7 +225,8 @@
- Watch-Only extension MUST be activated and have a wallet + Onchain Wallet (watch-only) extension MUST be activated and + have a wallet
diff --git a/lnbits/extensions/scrub/README.md b/lnbits/extensions/scrub/README.md new file mode 100644 index 000000000..680c5e6db --- /dev/null +++ b/lnbits/extensions/scrub/README.md @@ -0,0 +1,28 @@ +# Scrub + +## Automatically forward funds (Scrub) that get paid to the wallet to an LNURLpay or Lightning Address + +SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress! + +[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) + +## Usage + +1. Create an scrub (New Scrub link)\ + ![create scrub](https://i.imgur.com/LUeNkzM.jpg) + + - select the wallet to be _scrubbed_ + - make a small description + - enter either an LNURL pay or a lightning address + + Make sure your LNURL or LNaddress is correct! + +2. A new scrub will show on the _Scrub links_ section\ + ![scrub](https://i.imgur.com/LNoFkeu.jpg) + + - only one scrub can be created for each wallet! + - You can _edit_ or _delete_ the Scrub at any time\ + ![edit scrub](https://i.imgur.com/Qu65lGG.jpg) + +3. On your wallet, you'll see a transaction of a payment received and another right after it as apayment sent, marked with **#scrubed**\ + ![wallet view](https://i.imgur.com/S6EWWCP.jpg) diff --git a/lnbits/extensions/scrub/__init__.py b/lnbits/extensions/scrub/__init__.py new file mode 100644 index 000000000..777a7c3f9 --- /dev/null +++ b/lnbits/extensions/scrub/__init__.py @@ -0,0 +1,34 @@ +import asyncio + +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_scrub") + +scrub_static_files = [ + { + "path": "/scrub/static", + "app": StaticFiles(directory="lnbits/extensions/scrub/static"), + "name": "scrub_static", + } +] + +scrub_ext: APIRouter = APIRouter(prefix="/scrub", tags=["scrub"]) + + +def scrub_renderer(): + return template_renderer(["lnbits/extensions/scrub/templates"]) + + +from .tasks import wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa + + +def scrub_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/scrub/config.json b/lnbits/extensions/scrub/config.json new file mode 100644 index 000000000..df9e00389 --- /dev/null +++ b/lnbits/extensions/scrub/config.json @@ -0,0 +1,6 @@ +{ + "name": "Scrub", + "short_description": "Pass payments to LNURLp/LNaddress", + "icon": "send", + "contributors": ["arcbtc", "talvasconcelos"] +} diff --git a/lnbits/extensions/scrub/crud.py b/lnbits/extensions/scrub/crud.py new file mode 100644 index 000000000..1772a8c5b --- /dev/null +++ b/lnbits/extensions/scrub/crud.py @@ -0,0 +1,80 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import CreateScrubLink, ScrubLink + + +async def create_scrub_link(data: CreateScrubLink) -> ScrubLink: + scrub_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO scrub.scrub_links ( + id, + wallet, + description, + payoraddress + ) + VALUES (?, ?, ?, ?) + """, + ( + scrub_id, + data.wallet, + data.description, + data.payoraddress, + ), + ) + link = await get_scrub_link(scrub_id) + assert link, "Newly created link couldn't be retrieved" + return link + + +async def get_scrub_link(link_id: str) -> Optional[ScrubLink]: + row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,)) + return ScrubLink(**row) if row else None + + +async def get_scrub_links(wallet_ids: Union[str, List[str]]) -> List[ScrubLink]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f""" + SELECT * FROM scrub.scrub_links WHERE wallet IN ({q}) + ORDER BY id + """, + (*wallet_ids,), + ) + return [ScrubLink(**row) for row in rows] + + +async def update_scrub_link(link_id: int, **kwargs) -> Optional[ScrubLink]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE scrub.scrub_links SET {q} WHERE id = ?", + (*kwargs.values(), link_id), + ) + row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,)) + return ScrubLink(**row) if row else None + + +async def delete_scrub_link(link_id: int) -> None: + await db.execute("DELETE FROM scrub.scrub_links WHERE id = ?", (link_id,)) + + +async def get_scrub_by_wallet(wallet_id) -> Optional[ScrubLink]: + row = await db.fetchone( + "SELECT * from scrub.scrub_links WHERE wallet = ?", + (wallet_id,), + ) + return ScrubLink(**row) if row else None + + +async def unique_scrubed_wallet(wallet_id): + (row,) = await db.fetchone( + "SELECT COUNT(wallet) FROM scrub.scrub_links WHERE wallet = ?", + (wallet_id,), + ) + return row diff --git a/lnbits/extensions/scrub/migrations.py b/lnbits/extensions/scrub/migrations.py new file mode 100644 index 000000000..f8f2ba43c --- /dev/null +++ b/lnbits/extensions/scrub/migrations.py @@ -0,0 +1,14 @@ +async def m001_initial(db): + """ + Initial scrub table. + """ + await db.execute( + f""" + CREATE TABLE scrub.scrub_links ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + description TEXT NOT NULL, + payoraddress TEXT NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/scrub/models.py b/lnbits/extensions/scrub/models.py new file mode 100644 index 000000000..db05e4f17 --- /dev/null +++ b/lnbits/extensions/scrub/models.py @@ -0,0 +1,28 @@ +from sqlite3 import Row + +from pydantic import BaseModel +from starlette.requests import Request + +from lnbits.lnurl import encode as lnurl_encode # type: ignore + + +class CreateScrubLink(BaseModel): + wallet: str + description: str + payoraddress: str + + +class ScrubLink(BaseModel): + id: str + wallet: str + description: str + payoraddress: str + + @classmethod + def from_row(cls, row: Row) -> "ScrubLink": + data = dict(row) + return cls(**data) + + def lnurl(self, req: Request) -> str: + url = req.url_for("scrub.api_lnurl_response", link_id=self.id) + return lnurl_encode(url) diff --git a/lnbits/extensions/scrub/static/js/index.js b/lnbits/extensions/scrub/static/js/index.js new file mode 100644 index 000000000..439907921 --- /dev/null +++ b/lnbits/extensions/scrub/static/js/index.js @@ -0,0 +1,143 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +var locationPath = [ + window.location.protocol, + '//', + window.location.host, + window.location.pathname +].join('') + +var mapScrubLink = obj => { + obj._data = _.clone(obj) + obj.date = Quasar.utils.date.formatDate( + new Date(obj.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount) + obj.print_url = [locationPath, 'print/', obj.id].join('') + obj.pay_url = [locationPath, obj.id].join('') + return obj +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + checker: null, + payLinks: [], + payLinksTable: { + pagination: { + rowsPerPage: 10 + } + }, + formDialog: { + show: false, + data: {} + }, + qrCodeDialog: { + show: false, + data: null + } + } + }, + methods: { + getScrubLinks() { + LNbits.api + .request( + 'GET', + '/scrub/api/v1/links?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(response => { + this.payLinks = response.data.map(mapScrubLink) + }) + .catch(err => { + clearInterval(this.checker) + LNbits.utils.notifyApiError(err) + }) + }, + closeFormDialog() { + this.resetFormData() + }, + openUpdateDialog(linkId) { + const link = _.findWhere(this.payLinks, {id: linkId}) + + this.formDialog.data = _.clone(link._data) + this.formDialog.show = true + }, + sendFormData() { + const wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + let data = Object.freeze(this.formDialog.data) + console.log(wallet, data) + + if (data.id) { + this.updateScrubLink(wallet, data) + } else { + this.createScrubLink(wallet, data) + } + }, + resetFormData() { + this.formDialog = { + show: false, + data: {} + } + }, + updateScrubLink(wallet, data) { + LNbits.api + .request('PUT', '/scrub/api/v1/links/' + data.id, wallet.adminkey, data) + .then(response => { + this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id) + this.payLinks.push(mapScrubLink(response.data)) + this.formDialog.show = false + this.resetFormData() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + createScrubLink(wallet, data) { + LNbits.api + .request('POST', '/scrub/api/v1/links', wallet.adminkey, data) + .then(response => { + console.log('RES', response) + this.getScrubLinks() + this.formDialog.show = false + this.resetFormData() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + deleteScrubLink(linkId) { + var link = _.findWhere(this.payLinks, {id: linkId}) + + LNbits.utils + .confirmDialog('Are you sure you want to delete this pay link?') + .onOk(() => { + LNbits.api + .request( + 'DELETE', + '/scrub/api/v1/links/' + linkId, + _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey + ) + .then(response => { + this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + } + }, + created() { + if (this.g.user.wallets.length) { + var getScrubLinks = this.getScrubLinks + getScrubLinks() + } + } +}) diff --git a/lnbits/extensions/scrub/tasks.py b/lnbits/extensions/scrub/tasks.py new file mode 100644 index 000000000..87e1364b7 --- /dev/null +++ b/lnbits/extensions/scrub/tasks.py @@ -0,0 +1,85 @@ +import asyncio +import json +from http import HTTPStatus +from urllib.parse import urlparse + +import httpx +from fastapi import HTTPException + +from lnbits import bolt11 +from lnbits.core.models import Payment +from lnbits.core.services import pay_invoice +from lnbits.tasks import register_invoice_listener + +from .crud import get_scrub_by_wallet + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + # (avoid loops) + if "scrubed" == payment.extra.get("tag"): + # already scrubbed + return + + scrub_link = await get_scrub_by_wallet(payment.wallet_id) + + if not scrub_link: + return + + from lnbits.core.views.api import api_lnurlscan + + # DECODE LNURLP OR LNADDRESS + data = await api_lnurlscan(scrub_link.payoraddress) + + # I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267 + domain = urlparse(data["callback"]).netloc + + async with httpx.AsyncClient() as client: + try: + r = await client.get( + data["callback"], + params={"amount": payment.amount}, + timeout=40, + ) + if r.is_error: + raise httpx.ConnectError + except (httpx.ConnectError, httpx.RequestError): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to connect to {domain}.", + ) + + params = json.loads(r.text) + if params.get("status") == "ERROR": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} said: '{params.get('reason', '')}'", + ) + + invoice = bolt11.decode(params["pr"]) + if invoice.amount_msat != payment.amount: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.", + ) + + payment_hash = await pay_invoice( + wallet_id=payment.wallet_id, + payment_request=params["pr"], + description=data["description"], + extra={"tag": "scrubed"}, + ) + + return { + "payment_hash": payment_hash, + # maintain backwards compatibility with API clients: + "checking_id": payment_hash, + } diff --git a/lnbits/extensions/scrub/templates/scrub/_api_docs.html b/lnbits/extensions/scrub/templates/scrub/_api_docs.html new file mode 100644 index 000000000..ae3f44d88 --- /dev/null +++ b/lnbits/extensions/scrub/templates/scrub/_api_docs.html @@ -0,0 +1,136 @@ + + + + + GET /scrub/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<pay_link_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}scrub/api/v1/links?all_wallets=true + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /scrub/api/v1/links/<scrub_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {"id": <string>, "wallet": <string>, "description": + <string>, "payoraddress": <string>} +
Curl example
+ curl -X GET {{ request.base_url }}scrub/api/v1/links/<pay_id> + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + POST /scrub/api/v1/links +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"wallet": <string>, "description": <string>, + "payoraddress": <string>} +
+ Returns 201 CREATED (application/json) +
+ {"id": <string>, "wallet": <string>, "description": + <string>, "payoraddress": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}scrub/api/v1/links -d '{"wallet": + <string>, "description": <string>, "payoraddress": + <string>}' -H "Content-type: application/json" -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /scrub/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"wallet": <string>, "description": <string>, + "payoraddress": <string>} +
+ Returns 200 OK (application/json) +
+ {"id": <string>, "wallet": <string>, "description": + <string>, "payoraddress": <string>} +
Curl example
+ curl -X PUT {{ request.base_url }}scrub/api/v1/links/<pay_id> + -d '{"wallet": <string>, "description": <string>, + "payoraddress": <string>}' -H "Content-type: application/json" + -H "X-Api-Key: {{ user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /scrub/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.base_url + }}scrub/api/v1/links/<pay_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/scrub/templates/scrub/_lnurl.html b/lnbits/extensions/scrub/templates/scrub/_lnurl.html new file mode 100644 index 000000000..da46d9c47 --- /dev/null +++ b/lnbits/extensions/scrub/templates/scrub/_lnurl.html @@ -0,0 +1,28 @@ + + + +

+ WARNING: LNURL must be used over https or TOR
+ LNURL is a range of lightning-network standards that allow us to use + lightning-network differently. An LNURL-pay is a link that wallets use + to fetch an invoice from a server on-demand. The link or QR code is + fixed, but each time it is read by a compatible wallet a new QR code is + issued by the service. It can be used to activate machines without them + having to maintain an electronic screen to generate and show invoices + locally, or to sell any predefined good or service automatically. +

+

+ Exploring LNURL and finding use cases, is really helping inform + lightning protocol development, rather than the protocol dictating how + lightning-network should be engaged with. +

+ Check + Awesome LNURL + for further information. +
+
+
diff --git a/lnbits/extensions/scrub/templates/scrub/index.html b/lnbits/extensions/scrub/templates/scrub/index.html new file mode 100644 index 000000000..c063c858e --- /dev/null +++ b/lnbits/extensions/scrub/templates/scrub/index.html @@ -0,0 +1,140 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New scrub link + + + + + +
+
+
Scrub links
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
{{SITE_TITLE}} Scrub extension
+
+ + + + {% include "scrub/_api_docs.html" %} + + {% include "scrub/_lnurl.html" %} + + +
+
+ + + + + + + + + +
+ Update pay link + Create pay link + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/scrub/views.py b/lnbits/extensions/scrub/views.py new file mode 100644 index 000000000..73c7ffd9d --- /dev/null +++ b/lnbits/extensions/scrub/views.py @@ -0,0 +1,18 @@ +from fastapi import Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import scrub_ext, scrub_renderer + +templates = Jinja2Templates(directory="templates") + + +@scrub_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return scrub_renderer().TemplateResponse( + "scrub/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/scrub/views_api.py b/lnbits/extensions/scrub/views_api.py new file mode 100644 index 000000000..3714a3043 --- /dev/null +++ b/lnbits/extensions/scrub/views_api.py @@ -0,0 +1,112 @@ +from http import HTTPStatus + +from fastapi import Request +from fastapi.param_functions import Query +from fastapi.params import Depends +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key + +from . import scrub_ext +from .crud import ( + create_scrub_link, + delete_scrub_link, + get_scrub_link, + get_scrub_links, + unique_scrubed_wallet, + update_scrub_link, +) +from .models import CreateScrubLink + + +@scrub_ext.get("/api/v1/links", status_code=HTTPStatus.OK) +async def api_links( + req: Request, + wallet: WalletTypeInfo = Depends(get_key_type), + all_wallets: bool = Query(False), +): + wallet_ids = [wallet.wallet.id] + + if all_wallets: + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + + try: + return [link.dict() for link in await get_scrub_links(wallet_ids)] + + except: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No SCRUB links made yet", + ) + + +@scrub_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +async def api_link_retrieve( + r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + link = await get_scrub_link(link_id) + + if not link: + raise HTTPException( + detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if link.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN + ) + + return link + + +@scrub_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) +@scrub_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +async def api_scrub_create_or_update( + data: CreateScrubLink, + link_id=None, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + if link_id: + link = await get_scrub_link(link_id) + + if not link: + raise HTTPException( + detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if link.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN + ) + + link = await update_scrub_link(**data.dict(), link_id=link_id) + else: + wallet_has_scrub = await unique_scrubed_wallet(wallet_id=data.wallet) + if wallet_has_scrub > 0: + raise HTTPException( + detail="Wallet is already being Scrubbed", + status_code=HTTPStatus.FORBIDDEN, + ) + link = await create_scrub_link(data=data) + + return link + + +@scrub_ext.delete("/api/v1/links/{link_id}") +async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)): + link = await get_scrub_link(link_id) + + if not link: + raise HTTPException( + detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if link.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN + ) + + await delete_scrub_link(link_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) diff --git a/lnbits/extensions/streamalerts/README.md b/lnbits/extensions/streamalerts/README.md index 726ffe767..e19ff277a 100644 --- a/lnbits/extensions/streamalerts/README.md +++ b/lnbits/extensions/streamalerts/README.md @@ -18,7 +18,7 @@ In the "Whitelist Users" field, input the username of a Twitch account you contr For now, simply set the "Redirect URI" to `http://localhost`, you will change this soon. Then, hit create: ![image](https://user-images.githubusercontent.com/28876473/127759264-ae91539a-5694-4096-a478-80eb02b7b594.png) -1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Watch Only (to accept on-chain donations) extenions: +1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Onchain Wallet (watch-only) (to accept on-chain donations) extenions: ![image](https://user-images.githubusercontent.com/28876473/127759486-0e3420c2-c498-4bf9-932e-0abfa17bd478.png) 1. Create a "NEW SERVICE" using the button. Fill in all the information (you get your Client ID and Secret from the Streamlabs App page): ![image](https://user-images.githubusercontent.com/28876473/127759512-8e8b4e90-2a64-422a-bf0a-5508d0630bed.png) diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/index.html b/lnbits/extensions/streamalerts/templates/streamalerts/index.html index 46d1bb313..e86bc8b73 100644 --- a/lnbits/extensions/streamalerts/templates/streamalerts/index.html +++ b/lnbits/extensions/streamalerts/templates/streamalerts/index.html @@ -168,7 +168,8 @@
- Watch-Only extension MUST be activated and have a wallet + Onchain Wallet (watch-only) extension MUST be activated and + have a wallet
diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md index d93f7162d..be7bf351b 100644 --- a/lnbits/extensions/watchonly/README.md +++ b/lnbits/extensions/watchonly/README.md @@ -1,19 +1,85 @@ -# Watch Only wallet +# Onchain Wallet (watch-only) ## Monitor an onchain wallet and generate addresses for onchain payments Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API. -1. Start by clicking "NEW WALLET"\ - ![new wallet](https://i.imgur.com/vgbAB7c.png) -2. Fill the requested fields: - - give the wallet a name - - paste an Extended Public Key (xpub, ypub, zpub) - - click "CREATE WATCH-ONLY WALLET"\ - ![fill wallet form](https://i.imgur.com/UVoG7LD.png) -3. You can then access your onchain addresses\ - ![get address](https://i.imgur.com/zkxTQ6l.png) -4. You can then generate bitcoin onchain adresses from LNbits\ - ![onchain address](https://i.imgur.com/4KVSSJn.png) - You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension + +### Wallet Account + - a user can add one or more `xPubs` or `descriptors` + - the `xPub` fingerprint must be unique per user + - such and entry is called an `Wallet Account` + - the addresses in a `Wallet Account` are split into `Receive Addresses` and `Change Address` + - the user interacts directly only with the `Receive Addresses` (by sharing them) + - see [BIP44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account-discovery) for more details + - same `xPub` will always generate the same addresses (deterministic) + - when a `Wallet Account` is created, there are generated `20 Receive Addresses` and `5 Change Address` + - the limits can be change from the `Config` page (see `screenshot 1`) + - regular wallets only scan up to `20` empty receive addresses. If the user generates addresses beyond this limit a warning is shown (see `screenshot 4`) + +### Scan Blockchain + - when the user clicks `Scan Blockchain`, the wallet will loop over the all addresses (for each account) + - if funds are found, then the list is extended + - will scan addresses for all wallet accounts + - the search is done on the client-side (using the `mempool.space` API). `mempool.space` has a limit on the number of req/sec, therefore it is expected for the scanning to start fast, but slow down as more HTTP requests have to be retried + - addresses can also be rescanned individually form the `Address Details` section (`Addresses` tab) of each address + +### New Receive Address + - the `New Receive Address` button show the user the NEXT un-used address + - un-used means funds have not already been sent to that address AND the address has not already been shared + - internally there is a counter that keeps track of the last shared address + - it is possible to add a `Note` to each address in order to remember when/with whom it was shared + - mind the gap (`screenshot 4`) + +### Addresses Tab +- the `Addresses` tab contains a list with the addresses for all the `Wallet Accounts` + - only one entry per address will be shown (even if there are multiple UTXOs at that address) + - several filter criteria can be applied + - unconfirmed funds are also taken into account + - `Address Details` can be viewed by clicking the `Expand` button + +### History Tap + - shows the chronological order of transactions + - it shows unconfirmed transactions at the top + - it can be exported as CSV file + +### Coins Tab + - shows the UTXOs for all wallets + - there can be multiple UTXOs for the same address + +### Make Payment + - create a new `Partially Signed Bitcoin Transaction` + - multiple `Send Addresses` can be added + - the `Max` button next to an address is for sending the remaining funds to this address (no change) + - the user can select the inputs (UTXOs) manually, or it can use of the basic selection algorithms + - amounts have to be provided for the `Send Addresses` beforehand (so the algorithm knows the amount to be selected) + - `Show Advanced` allows to (see `screenshot 2`): + - select from which account the change address will be selected (defaults to the first one) + - select the `Fee Rate` + - it defaults to the `Medium` value at the moment the `Make Payment` button was clicked + - it can be refreshed + - warnings are shown if the fee is too Low or to High + +### Create PSBT + - based on the Inputs & Outputs selected by the user a PSBT will be generated + - this wallet is watch-only, therefore does not support signing + - it is not mandatory for the `Selected Amount` to be grater than `Payed Amount` + - the generated PSBT can be combined with other PSBTs that add more inputs. + - the generated PSBT can be imported for signing into different wallets like Electrum + - import the PSBT into Electrum and check the In/Outs/Fee (see `screenshot 3`) + +## Screensots +- screenshot 1: +![image](https://user-images.githubusercontent.com/2951406/177181611-eeeac70c-c245-4b45-b80b-8bbb511f6d1d.png) + +- screenshot 2: +![image](https://user-images.githubusercontent.com/2951406/177331468-f9b43626-548a-4608-b0d0-44007f402404.png) + +- screenshot 3: +![image](https://user-images.githubusercontent.com/2951406/177333755-4a9118fb-3eaf-43d6-bc7e-c3d8c80bc61e.png) + +- screenshot 4: +![image](https://user-images.githubusercontent.com/2951406/177337474-bfcf7a7c-501a-4ebb-916e-ca391e63f6a7.png) + + diff --git a/lnbits/extensions/watchonly/__init__.py b/lnbits/extensions/watchonly/__init__.py index 0c5259803..a7fff888a 100644 --- a/lnbits/extensions/watchonly/__init__.py +++ b/lnbits/extensions/watchonly/__init__.py @@ -1,10 +1,18 @@ from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles from lnbits.db import Database from lnbits.helpers import template_renderer db = Database("ext_watchonly") +watchonly_static_files = [ + { + "path": "/watchonly/static", + "app": StaticFiles(directory="lnbits/extensions/watchonly/static"), + "name": "watchonly_static", + } +] watchonly_ext: APIRouter = APIRouter(prefix="/watchonly", tags=["watchonly"]) diff --git a/lnbits/extensions/watchonly/config.json b/lnbits/extensions/watchonly/config.json index 48c19ef07..6331418cd 100644 --- a/lnbits/extensions/watchonly/config.json +++ b/lnbits/extensions/watchonly/config.json @@ -1,8 +1,9 @@ { - "name": "Watch Only", + "name": "Onchain Wallet", "short_description": "Onchain watch only wallets", "icon": "visibility", "contributors": [ - "arcbtc" + "arcbtc", + "motorina0" ] } diff --git a/lnbits/extensions/watchonly/crud.py b/lnbits/extensions/watchonly/crud.py index 0ce3ead9d..b88a7df7e 100644 --- a/lnbits/extensions/watchonly/crud.py +++ b/lnbits/extensions/watchonly/crud.py @@ -1,81 +1,16 @@ +import json from typing import List, Optional -from embit.descriptor import Descriptor, Key # type: ignore -from embit.descriptor.arguments import AllowedDerivation # type: ignore -from embit.networks import NETWORKS # type: ignore - from lnbits.helpers import urlsafe_short_hash from . import db -from .models import Addresses, Mempool, Wallets +from .helpers import derive_address, parse_key +from .models import Address, Config, Mempool, WalletAccount ##########################WALLETS#################### -def detect_network(k): - version = k.key.version - for network_name in NETWORKS: - net = NETWORKS[network_name] - # not found in this network - if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]: - return net - - -def parse_key(masterpub: str): - """Parses masterpub or descriptor and returns a tuple: (Descriptor, network) - To create addresses use descriptor.derive(num).address(network=network) - """ - network = None - # probably a single key - if "(" not in masterpub: - k = Key.from_string(masterpub) - if not k.is_extended: - raise ValueError("The key is not a master public key") - if k.is_private: - raise ValueError("Private keys are not allowed") - # check depth - if k.key.depth != 3: - raise ValueError( - "Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors." - ) - # if allowed derivation is not provided use default /{0,1}/* - if k.allowed_derivation is None: - k.allowed_derivation = AllowedDerivation.default() - # get version bytes - version = k.key.version - for network_name in NETWORKS: - net = NETWORKS[network_name] - # not found in this network - if version in [net["xpub"], net["ypub"], net["zpub"]]: - network = net - if version == net["xpub"]: - desc = Descriptor.from_string("pkh(%s)" % str(k)) - elif version == net["ypub"]: - desc = Descriptor.from_string("sh(wpkh(%s))" % str(k)) - elif version == net["zpub"]: - desc = Descriptor.from_string("wpkh(%s)" % str(k)) - break - # we didn't find correct version - if network is None: - raise ValueError("Unknown master public key version") - else: - desc = Descriptor.from_string(masterpub) - if not desc.is_wildcard: - raise ValueError("Descriptor should have wildcards") - for k in desc.keys: - if k.is_extended: - net = detect_network(k) - if net is None: - raise ValueError(f"Unknown version: {k}") - if network is not None and network != net: - raise ValueError("Keys from different networks") - network = net - return desc, network - - -async def create_watch_wallet(user: str, masterpub: str, title: str) -> Wallets: - # check the masterpub is fine, it will raise an exception if not - parse_key(masterpub) +async def create_watch_wallet(w: WalletAccount) -> WalletAccount: wallet_id = urlsafe_short_hash() await db.execute( """ @@ -83,34 +18,44 @@ async def create_watch_wallet(user: str, masterpub: str, title: str) -> Wallets: id, "user", masterpub, + fingerprint, title, + type, address_no, balance ) - VALUES (?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - # address_no is -1 so fresh address on empty wallet can get address with index 0 - (wallet_id, user, masterpub, title, -1, 0), + ( + wallet_id, + w.user, + w.masterpub, + w.fingerprint, + w.title, + w.type, + w.address_no, + w.balance, + ), ) return await get_watch_wallet(wallet_id) -async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]: +async def get_watch_wallet(wallet_id: str) -> Optional[WalletAccount]: row = await db.fetchone( "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) ) - return Wallets.from_row(row) if row else None + return WalletAccount.from_row(row) if row else None -async def get_watch_wallets(user: str) -> List[Wallets]: +async def get_watch_wallets(user: str) -> List[WalletAccount]: rows = await db.fetchall( """SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,) ) - return [Wallets(**row) for row in rows] + return [WalletAccount(**row) for row in rows] -async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: +async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[WalletAccount]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) await db.execute( @@ -119,65 +64,184 @@ async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: row = await db.fetchone( "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) ) - return Wallets.from_row(row) if row else None + return WalletAccount.from_row(row) if row else None async def delete_watch_wallet(wallet_id: str) -> None: await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,)) - ########################ADDRESSES####################### + +########################ADDRESSES####################### -async def get_derive_address(wallet_id: str, num: int): - wallet = await get_watch_wallet(wallet_id) - key = wallet.masterpub - desc, network = parse_key(key) - return desc.derive(num).address(network=network) - - -async def get_fresh_address(wallet_id: str) -> Optional[Addresses]: +async def get_fresh_address(wallet_id: str) -> Optional[Address]: + # todo: move logic to views_api after satspay refactoring wallet = await get_watch_wallet(wallet_id) if not wallet: return None - address = await get_derive_address(wallet_id, wallet.address_no + 1) + wallet_addresses = await get_addresses(wallet_id) + receive_addresses = list( + filter( + lambda addr: addr.branch_index == 0 and addr.has_activity, wallet_addresses + ) + ) + last_receive_index = ( + receive_addresses.pop().address_index if receive_addresses else -1 + ) + address_index = ( + last_receive_index + if last_receive_index > wallet.address_no + else wallet.address_no + ) - await update_watch_wallet(wallet_id=wallet_id, address_no=wallet.address_no + 1) - masterpub_id = urlsafe_short_hash() - await db.execute( - """ + address = await get_address_at_index(wallet_id, 0, address_index + 1) + + if not address: + addresses = await create_fresh_addresses( + wallet_id, address_index + 1, address_index + 2 + ) + address = addresses.pop() + + await update_watch_wallet(wallet_id, **{"address_no": address_index + 1}) + + return address + + +async def create_fresh_addresses( + wallet_id: str, + start_address_index: int, + end_address_index: int, + change_address=False, +) -> List[Address]: + if start_address_index > end_address_index: + return None + + wallet = await get_watch_wallet(wallet_id) + if not wallet: + return None + + branch_index = 1 if change_address else 0 + + for address_index in range(start_address_index, end_address_index): + address = await derive_address(wallet.masterpub, address_index, branch_index) + + await db.execute( + """ INSERT INTO watchonly.addresses ( id, address, wallet, - amount + amount, + branch_index, + address_index ) - VALUES (?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?) """, - (masterpub_id, address, wallet_id, 0), + (urlsafe_short_hash(), address, wallet_id, 0, branch_index, address_index), + ) + + # return fresh addresses + rows = await db.fetchall( + """ + SELECT * FROM watchonly.addresses + WHERE wallet = ? AND branch_index = ? AND address_index >= ? AND address_index < ? + ORDER BY branch_index, address_index + """, + (wallet_id, branch_index, start_address_index, end_address_index), ) - return await get_address(address) + return [Address(**row) for row in rows] -async def get_address(address: str) -> Optional[Addresses]: +async def get_address(address: str) -> Optional[Address]: row = await db.fetchone( "SELECT * FROM watchonly.addresses WHERE address = ?", (address,) ) - return Addresses.from_row(row) if row else None + return Address.from_row(row) if row else None -async def get_addresses(wallet_id: str) -> List[Addresses]: - rows = await db.fetchall( - "SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,) +async def get_address_at_index( + wallet_id: str, branch_index: int, address_index: int +) -> Optional[Address]: + row = await db.fetchone( + """ + SELECT * FROM watchonly.addresses + WHERE wallet = ? AND branch_index = ? AND address_index = ? + """, + ( + wallet_id, + branch_index, + address_index, + ), ) - return [Addresses(**row) for row in rows] + return Address.from_row(row) if row else None + + +async def get_addresses(wallet_id: str) -> List[Address]: + rows = await db.fetchall( + """ + SELECT * FROM watchonly.addresses WHERE wallet = ? + ORDER BY branch_index, address_index + """, + (wallet_id,), + ) + + return [Address(**row) for row in rows] + + +async def update_address(id: str, **kwargs) -> Optional[Address]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"""UPDATE watchonly.addresses SET {q} WHERE id = ? """, + (*kwargs.values(), id), + ) + row = await db.fetchone("SELECT * FROM watchonly.addresses WHERE id = ?", (id)) + return Address.from_row(row) if row else None + + +async def delete_addresses_for_wallet(wallet_id: str) -> None: + await db.execute("DELETE FROM watchonly.addresses WHERE wallet = ?", (wallet_id,)) + + +######################CONFIG####################### +async def create_config(user: str) -> Config: + config = Config() + await db.execute( + """ + INSERT INTO watchonly.config ("user", json_data) + VALUES (?, ?) + """, + (user, json.dumps(config.dict())), + ) + row = await db.fetchone( + """SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,) + ) + return json.loads(row[0], object_hook=lambda d: Config(**d)) + + +async def update_config(config: Config, user: str) -> Optional[Config]: + await db.execute( + f"""UPDATE watchonly.config SET json_data = ? WHERE "user" = ?""", + (json.dumps(config.dict()), user), + ) + row = await db.fetchone( + """SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,) + ) + return json.loads(row[0], object_hook=lambda d: Config(**d)) + + +async def get_config(user: str) -> Optional[Config]: + row = await db.fetchone( + """SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,) + ) + return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None ######################MEMPOOL####################### - - +### TODO: fix statspay dependcy and remove async def create_mempool(user: str) -> Optional[Mempool]: await db.execute( """ @@ -192,6 +256,7 @@ async def create_mempool(user: str) -> Optional[Mempool]: return Mempool.from_row(row) if row else None +### TODO: fix statspay dependcy and remove async def update_mempool(user: str, **kwargs) -> Optional[Mempool]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) @@ -205,6 +270,7 @@ async def update_mempool(user: str, **kwargs) -> Optional[Mempool]: return Mempool.from_row(row) if row else None +### TODO: fix statspay dependcy and remove async def get_mempool(user: str) -> Mempool: row = await db.fetchone( """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) diff --git a/lnbits/extensions/watchonly/helpers.py b/lnbits/extensions/watchonly/helpers.py new file mode 100644 index 000000000..74125dde8 --- /dev/null +++ b/lnbits/extensions/watchonly/helpers.py @@ -0,0 +1,69 @@ +from embit.descriptor import Descriptor, Key # type: ignore +from embit.descriptor.arguments import AllowedDerivation # type: ignore +from embit.networks import NETWORKS # type: ignore + + +def detect_network(k): + version = k.key.version + for network_name in NETWORKS: + net = NETWORKS[network_name] + # not found in this network + if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]: + return net + + +def parse_key(masterpub: str) -> Descriptor: + """Parses masterpub or descriptor and returns a tuple: (Descriptor, network) + To create addresses use descriptor.derive(num).address(network=network) + """ + network = None + # probably a single key + if "(" not in masterpub: + k = Key.from_string(masterpub) + if not k.is_extended: + raise ValueError("The key is not a master public key") + if k.is_private: + raise ValueError("Private keys are not allowed") + # check depth + if k.key.depth != 3: + raise ValueError( + "Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors." + ) + # if allowed derivation is not provided use default /{0,1}/* + if k.allowed_derivation is None: + k.allowed_derivation = AllowedDerivation.default() + # get version bytes + version = k.key.version + for network_name in NETWORKS: + net = NETWORKS[network_name] + # not found in this network + if version in [net["xpub"], net["ypub"], net["zpub"]]: + network = net + if version == net["xpub"]: + desc = Descriptor.from_string("pkh(%s)" % str(k)) + elif version == net["ypub"]: + desc = Descriptor.from_string("sh(wpkh(%s))" % str(k)) + elif version == net["zpub"]: + desc = Descriptor.from_string("wpkh(%s)" % str(k)) + break + # we didn't find correct version + if network is None: + raise ValueError("Unknown master public key version") + else: + desc = Descriptor.from_string(masterpub) + if not desc.is_wildcard: + raise ValueError("Descriptor should have wildcards") + for k in desc.keys: + if k.is_extended: + net = detect_network(k) + if net is None: + raise ValueError(f"Unknown version: {k}") + if network is not None and network != net: + raise ValueError("Keys from different networks") + network = net + return desc, network + + +async def derive_address(masterpub: str, num: int, branch_index=0): + desc, network = parse_key(masterpub) + return desc.derive(num, branch_index).address(network=network) diff --git a/lnbits/extensions/watchonly/migrations.py b/lnbits/extensions/watchonly/migrations.py index 05c229b53..8e371a2a0 100644 --- a/lnbits/extensions/watchonly/migrations.py +++ b/lnbits/extensions/watchonly/migrations.py @@ -34,3 +34,50 @@ async def m001_initial(db): ); """ ) + + +async def m002_add_columns_to_adresses(db): + """ + Add 'branch_index', 'address_index', 'has_activity' and 'note' columns to the 'addresses' table + """ + + await db.execute( + "ALTER TABLE watchonly.addresses ADD COLUMN branch_index INTEGER NOT NULL DEFAULT 0;" + ) + await db.execute( + "ALTER TABLE watchonly.addresses ADD COLUMN address_index INTEGER NOT NULL DEFAULT 0;" + ) + await db.execute( + "ALTER TABLE watchonly.addresses ADD COLUMN has_activity BOOLEAN DEFAULT false;" + ) + await db.execute("ALTER TABLE watchonly.addresses ADD COLUMN note TEXT;") + + +async def m003_add_columns_to_wallets(db): + """ + Add 'type' and 'fingerprint' columns to the 'wallets' table + """ + + await db.execute("ALTER TABLE watchonly.wallets ADD COLUMN type TEXT;") + await db.execute( + "ALTER TABLE watchonly.wallets ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';" + ) + + +async def m004_create_config_table(db): + """ + Allow the extension to persist and retrieve any number of config values. + Each user has its configurations saved as a JSON string + """ + + await db.execute( + """CREATE TABLE watchonly.config ( + "user" TEXT NOT NULL, + json_data TEXT NOT NULL + );""" + ) + + ### TODO: fix statspay dependcy first + # await db.execute( + # "DROP TABLE watchonly.wallets;" + # ) diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py index d0894097f..bc10e4210 100644 --- a/lnbits/extensions/watchonly/models.py +++ b/lnbits/extensions/watchonly/models.py @@ -1,4 +1,5 @@ from sqlite3 import Row +from typing import List from fastapi.param_functions import Query from pydantic import BaseModel @@ -9,19 +10,22 @@ class CreateWallet(BaseModel): title: str = Query("") -class Wallets(BaseModel): +class WalletAccount(BaseModel): id: str user: str masterpub: str + fingerprint: str title: str address_no: int balance: int + type: str = "" @classmethod - def from_row(cls, row: Row) -> "Wallets": + def from_row(cls, row: Row) -> "WalletAccount": return cls(**dict(row)) +### TODO: fix statspay dependcy and remove class Mempool(BaseModel): user: str endpoint: str @@ -31,12 +35,55 @@ class Mempool(BaseModel): return cls(**dict(row)) -class Addresses(BaseModel): +class Address(BaseModel): id: str address: str wallet: str - amount: int + amount: int = 0 + branch_index: int = 0 + address_index: int + note: str = None + has_activity: bool = False @classmethod - def from_row(cls, row: Row) -> "Addresses": + def from_row(cls, row: Row) -> "Address": return cls(**dict(row)) + + +class TransactionInput(BaseModel): + tx_id: str + vout: int + amount: int + address: str + branch_index: int + address_index: int + masterpub_fingerprint: str + tx_hex: str + + +class TransactionOutput(BaseModel): + amount: int + address: str + branch_index: int = None + address_index: int = None + masterpub_fingerprint: str = None + + +class MasterPublicKey(BaseModel): + public_key: str + fingerprint: str + + +class CreatePsbt(BaseModel): + masterpubs: List[MasterPublicKey] + inputs: List[TransactionInput] + outputs: List[TransactionOutput] + fee_rate: int + tx_size: int + + +class Config(BaseModel): + mempool_endpoint = "https://mempool.space" + receive_gap_limit = 20 + change_gap_limit = 5 + sats_denominated = True diff --git a/lnbits/extensions/watchonly/static/js/index.js b/lnbits/extensions/watchonly/static/js/index.js new file mode 100644 index 000000000..5eee21761 --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/index.js @@ -0,0 +1,735 @@ +Vue.component(VueQrcode.name, VueQrcode) + +Vue.filter('reverse', function (value) { + // slice to make a copy of array, then reverse the copy + return value.slice().reverse() +}) + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + DUST_LIMIT: 546, + filter: '', + + scan: { + scanning: false, + scanCount: 0, + scanIndex: 0 + }, + + currentAddress: null, + + tab: 'addresses', + + config: { + data: { + mempool_endpoint: 'https://mempool.space', + receive_gap_limit: 20, + change_gap_limit: 5 + }, + DEFAULT_RECEIVE_GAP_LIMIT: 20, + show: false + }, + + formDialog: { + show: false, + data: {} + }, + + qrCodeDialog: { + show: false, + data: null + }, + ...tables, + ...tableData + } + }, + + methods: { + //################### CONFIG ################### + getConfig: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/config', + this.g.user.wallets[0].adminkey + ) + this.config.data = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + updateConfig: async function () { + const wallet = this.g.user.wallets[0] + try { + await LNbits.api.request( + 'PUT', + '/watchonly/api/v1/config', + wallet.adminkey, + this.config.data + ) + this.config.show = false + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + //################### WALLETS ################### + getWalletName: function (walletId) { + const wallet = this.walletAccounts.find(wl => wl.id === walletId) + return wallet ? wallet.title : 'unknown' + }, + addWalletAccount: async function () { + const wallet = this.g.user.wallets[0] + const data = _.omit(this.formDialog.data, 'wallet') + await this.createWalletAccount(wallet, data) + }, + createWalletAccount: async function (wallet, data) { + try { + const response = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/wallet', + wallet.adminkey, + data + ) + this.walletAccounts.push(mapWalletAccount(response.data)) + this.formDialog.show = false + + await this.refreshWalletAccounts() + await this.refreshAddresses() + + if (!this.payment.changeWallett) { + this.payment.changeWallet = this.walletAccounts[0] + this.selectChangeAddress(this.payment.changeWallet) + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + deleteWalletAccount: function (walletAccountId) { + LNbits.utils + .confirmDialog( + 'Are you sure you want to delete this watch only wallet?' + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/watchonly/api/v1/wallet/' + walletAccountId, + this.g.user.wallets[0].adminkey + ) + this.walletAccounts = _.reject(this.walletAccounts, function (obj) { + return obj.id === walletAccountId + }) + await this.refreshWalletAccounts() + await this.refreshAddresses() + if ( + this.payment.changeWallet && + this.payment.changeWallet.id === walletAccountId + ) { + this.payment.changeWallet = this.walletAccounts[0] + this.selectChangeAddress(this.payment.changeWallet) + } + await this.scanAddressWithAmount() + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Error while deleting wallet account. Please try again.', + timeout: 10000 + }) + } + }) + }, + getAddressesForWallet: async function (walletId) { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/addresses/' + walletId, + this.g.user.wallets[0].inkey + ) + return data.map(mapAddressesData) + } catch (err) { + this.$q.notify({ + type: 'warning', + message: `Failed to fetch addresses for wallet with id ${walletId}.`, + timeout: 10000 + }) + LNbits.utils.notifyApiError(err) + } + return [] + }, + getWatchOnlyWallets: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/wallet', + this.g.user.wallets[0].inkey + ) + return data + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to fetch wallets.', + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + } + return [] + }, + refreshWalletAccounts: async function () { + const wallets = await this.getWatchOnlyWallets() + this.walletAccounts = wallets.map(w => mapWalletAccount(w)) + }, + getAmmountForWallet: function (walletId) { + const amount = this.addresses.data + .filter(a => a.wallet === walletId) + .reduce((t, a) => t + a.amount || 0, 0) + return this.satBtc(amount) + }, + + //################### ADDRESSES ################### + + refreshAddresses: async function () { + const wallets = await this.getWatchOnlyWallets() + this.addresses.data = [] + for (const {id, type} of wallets) { + const newAddresses = await this.getAddressesForWallet(id) + const uniqueAddresses = newAddresses.filter( + newAddr => + !this.addresses.data.find(a => a.address === newAddr.address) + ) + + const lastAcctiveAddress = + uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() || {} + + uniqueAddresses.forEach(a => { + a.expanded = false + a.accountType = type + a.gapLimitExceeded = + !a.isChange && + a.addressIndex > + lastAcctiveAddress.addressIndex + + this.config.DEFAULT_RECEIVE_GAP_LIMIT + }) + this.addresses.data.push(...uniqueAddresses) + } + }, + updateAmountForAddress: async function (addressData, amount = 0) { + try { + const wallet = this.g.user.wallets[0] + addressData.amount = amount + if (!addressData.isChange) { + const addressWallet = this.walletAccounts.find( + w => w.id === addressData.wallet + ) + if ( + addressWallet && + addressWallet.address_no < addressData.addressIndex + ) { + addressWallet.address_no = addressData.addressIndex + } + } + + await LNbits.api.request( + 'PUT', + `/watchonly/api/v1/address/${addressData.id}`, + wallet.adminkey, + {amount} + ) + } catch (err) { + addressData.error = 'Failed to refresh amount for address' + this.$q.notify({ + type: 'warning', + message: `Failed to refresh amount for address ${addressData.address}`, + timeout: 10000 + }) + LNbits.utils.notifyApiError(err) + } + }, + updateNoteForAddress: async function (addressData, note) { + try { + const wallet = this.g.user.wallets[0] + await LNbits.api.request( + 'PUT', + `/watchonly/api/v1/address/${addressData.id}`, + wallet.adminkey, + {note: addressData.note} + ) + const updatedAddress = + this.addresses.data.find(a => a.id === addressData.id) || {} + updatedAddress.note = note + } catch (err) { + LNbits.utils.notifyApiError(err) + } + }, + getFilteredAddresses: function () { + const selectedWalletId = this.addresses.selectedWallet?.id + const filter = this.addresses.filterValues || [] + const includeChangeAddrs = filter.includes('Show Change Addresses') + const includeGapAddrs = filter.includes('Show Gap Addresses') + const excludeNoAmount = filter.includes('Only With Amount') + + const walletsLimit = this.walletAccounts.reduce((r, w) => { + r[`_${w.id}`] = w.address_no + return r + }, {}) + + const addresses = this.addresses.data.filter( + a => + (includeChangeAddrs || !a.isChange) && + (includeGapAddrs || + a.isChange || + a.addressIndex <= walletsLimit[`_${a.wallet}`]) && + !(excludeNoAmount && a.amount === 0) && + (!selectedWalletId || a.wallet === selectedWalletId) + ) + return addresses + }, + openGetFreshAddressDialog: async function (walletId) { + const {data} = await LNbits.api.request( + 'GET', + `/watchonly/api/v1/address/${walletId}`, + this.g.user.wallets[0].inkey + ) + const addressData = mapAddressesData(data) + + addressData.note = `Shared on ${currentDateTime()}` + const lastAcctiveAddress = + this.addresses.data + .filter( + a => a.wallet === addressData.wallet && !a.isChange && a.hasActivity + ) + .pop() || {} + addressData.gapLimitExceeded = + !addressData.isChange && + addressData.addressIndex > + lastAcctiveAddress.addressIndex + + this.config.DEFAULT_RECEIVE_GAP_LIMIT + + this.openQrCodeDialog(addressData) + const wallet = this.walletAccounts.find(w => w.id === walletId) || {} + wallet.address_no = addressData.addressIndex + await this.refreshAddresses() + }, + + //################### ADDRESS HISTORY ################### + addressHistoryFromTxs: function (addressData, txs) { + const addressHistory = [] + txs.forEach(tx => { + const sent = tx.vin + .filter( + vin => vin.prevout.scriptpubkey_address === addressData.address + ) + .map(vin => mapInputToSentHistory(tx, addressData, vin)) + + const received = tx.vout + .filter(vout => vout.scriptpubkey_address === addressData.address) + .map(vout => mapOutputToReceiveHistory(tx, addressData, vout)) + addressHistory.push(...sent, ...received) + }) + return addressHistory + }, + getFilteredAddressesHistory: function () { + return this.addresses.history.filter( + a => (!a.isChange || a.sent) && !a.isSubItem + ) + }, + exportHistoryToCSV: function () { + const history = this.getFilteredAddressesHistory().map(a => ({ + ...a, + action: a.sent ? 'Sent' : 'Received' + })) + LNbits.utils.exportCSV( + this.historyTable.exportColums, + history, + 'address-history' + ) + }, + markSameTxAddressHistory: function () { + this.addresses.history + .filter(s => s.sent) + .forEach((el, i, arr) => { + if (el.isSubItem) return + + const sameTxItems = arr.slice(i + 1).filter(e => e.txId === el.txId) + if (!sameTxItems.length) return + sameTxItems.forEach(e => { + e.isSubItem = true + }) + + el.totalAmount = + el.amount + sameTxItems.reduce((t, e) => (t += e.amount || 0), 0) + el.sameTxItems = sameTxItems + }) + }, + showAddressHistoryDetails: function (addressHistory) { + addressHistory.expanded = true + }, + + //################### PAYMENT ################### + createTx: function (excludeChange = false) { + const tx = { + fee_rate: this.payment.feeRate, + tx_size: this.payment.txSize, + masterpubs: this.walletAccounts.map(w => ({ + public_key: w.masterpub, + fingerprint: w.fingerprint + })) + } + tx.inputs = this.utxos.data + .filter(utxo => utxo.selected) + .map(mapUtxoToPsbtInput) + .sort((a, b) => + a.tx_id < b.tx_id ? -1 : a.tx_id > b.tx_id ? 1 : a.vout - b.vout + ) + + tx.outputs = this.payment.data.map(out => ({ + address: out.address, + amount: out.amount + })) + + if (excludeChange) { + this.payment.changeAmount = 0 + } else { + const change = this.createChangeOutput() + this.payment.changeAmount = change.amount + if (change.amount >= this.DUST_LIMIT) { + tx.outputs.push(change) + } + } + // Only sort by amount on UI level (no lib for address decode) + // Should sort by scriptPubKey (as byte array) on the backend + tx.outputs.sort((a, b) => a.amount - b.amount) + + return tx + }, + createChangeOutput: function () { + const change = this.payment.changeAddress + const fee = this.payment.feeRate * this.payment.txSize + const inputAmount = this.getTotalSelectedUtxoAmount() + const payedAmount = this.getTotalPaymentAmount() + const walletAcount = + this.walletAccounts.find(w => w.id === change.wallet) || {} + + return { + address: change.address, + amount: inputAmount - payedAmount - fee, + addressIndex: change.addressIndex, + addressIndex: change.addressIndex, + masterpub_fingerprint: walletAcount.fingerprint + } + }, + computeFee: function () { + const tx = this.createTx() + this.payment.txSize = Math.round(txSize(tx)) + return this.payment.feeRate * this.payment.txSize + }, + createPsbt: async function () { + const wallet = this.g.user.wallets[0] + try { + this.computeFee() + const tx = this.createTx() + txSize(tx) + for (const input of tx.inputs) { + input.tx_hex = await this.fetchTxHex(input.tx_id) + } + + const {data} = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/psbt', + wallet.adminkey, + tx + ) + + this.payment.psbtBase64 = data + } catch (err) { + LNbits.utils.notifyApiError(err) + } + }, + deletePaymentAddress: function (v) { + const index = this.payment.data.indexOf(v) + if (index !== -1) { + this.payment.data.splice(index, 1) + } + }, + initPaymentData: async function () { + if (!this.payment.show) return + await this.refreshAddresses() + + this.payment.showAdvanced = false + this.payment.changeWallet = this.walletAccounts[0] + this.selectChangeAddress(this.payment.changeWallet) + + await this.refreshRecommendedFees() + this.payment.feeRate = this.payment.recommededFees.halfHourFee + }, + getFeeRateLabel: function (feeRate) { + const fees = this.payment.recommededFees + if (feeRate >= fees.fastestFee) return `High Priority (${feeRate} sat/vB)` + if (feeRate >= fees.halfHourFee) + return `Medium Priority (${feeRate} sat/vB)` + if (feeRate >= fees.hourFee) return `Low Priority (${feeRate} sat/vB)` + return `No Priority (${feeRate} sat/vB)` + }, + addPaymentAddress: function () { + this.payment.data.push({address: '', amount: undefined}) + }, + getTotalPaymentAmount: function () { + return this.payment.data.reduce((t, a) => t + (a.amount || 0), 0) + }, + selectChangeAddress: function (wallet = {}) { + this.payment.changeAddress = + this.addresses.data.find( + a => a.wallet === wallet.id && a.isChange && !a.hasActivity + ) || {} + }, + goToPaymentView: async function () { + this.payment.show = true + this.tab = 'utxos' + await this.initPaymentData() + }, + sendMaxToAddress: function (paymentAddress = {}) { + paymentAddress.amount = 0 + const tx = this.createTx(true) + this.payment.txSize = Math.round(txSize(tx)) + const fee = this.payment.feeRate * this.payment.txSize + const inputAmount = this.getTotalSelectedUtxoAmount() + const payedAmount = this.getTotalPaymentAmount() + paymentAddress.amount = Math.max(0, inputAmount - payedAmount - fee) + }, + + //################### UTXOs ################### + scanAllAddresses: async function () { + await this.refreshAddresses() + this.addresses.history = [] + let addresses = this.addresses.data + this.utxos.data = [] + this.utxos.total = 0 + // Loop while new funds are found on the gap adresses. + // Use 1000 limit as a safety check (scan 20 000 addresses max) + for (let i = 0; i < 1000 && addresses.length; i++) { + await this.updateUtxosForAddresses(addresses) + const oldAddresses = this.addresses.data.slice() + await this.refreshAddresses() + const newAddresses = this.addresses.data.slice() + // check if gap addresses have been extended + addresses = newAddresses.filter( + newAddr => !oldAddresses.find(oldAddr => oldAddr.id === newAddr.id) + ) + if (addresses.length) { + this.$q.notify({ + type: 'positive', + message: 'Funds found! Scanning for more...', + timeout: 10000 + }) + } + } + }, + scanAddressWithAmount: async function () { + this.utxos.data = [] + this.utxos.total = 0 + this.addresses.history = [] + const addresses = this.addresses.data.filter(a => a.hasActivity) + await this.updateUtxosForAddresses(addresses) + }, + scanAddress: async function (addressData) { + this.updateUtxosForAddresses([addressData]) + this.$q.notify({ + type: 'positive', + message: 'Address Rescanned', + timeout: 10000 + }) + }, + updateUtxosForAddresses: async function (addresses = []) { + this.scan = {scanning: true, scanCount: addresses.length, scanIndex: 0} + + try { + for (addrData of addresses) { + const addressHistory = await this.getAddressTxsDelayed(addrData) + // remove old entries + this.addresses.history = this.addresses.history.filter( + h => h.address !== addrData.address + ) + + // add new entrie + this.addresses.history.push(...addressHistory) + this.addresses.history.sort((a, b) => + !a.height ? -1 : b.height - a.height + ) + this.markSameTxAddressHistory() + + if (addressHistory.length) { + // search only if it ever had any activity + const utxos = await this.getAddressTxsUtxoDelayed(addrData.address) + this.updateUtxosForAddress(addrData, utxos) + } + + this.scan.scanIndex++ + } + } catch (error) { + console.error(error) + this.$q.notify({ + type: 'warning', + message: 'Failed to scan addresses', + timeout: 10000 + }) + } finally { + this.scan.scanning = false + } + }, + updateUtxosForAddress: function (addressData, utxos = []) { + const wallet = + this.walletAccounts.find(w => w.id === addressData.wallet) || {} + + const newUtxos = utxos.map(utxo => + mapAddressDataToUtxo(wallet, addressData, utxo) + ) + // remove old utxos + this.utxos.data = this.utxos.data.filter( + u => u.address !== addressData.address + ) + // add new utxos + this.utxos.data.push(...newUtxos) + if (utxos.length) { + this.utxos.data.sort((a, b) => b.sort - a.sort) + this.utxos.total = this.utxos.data.reduce( + (total, y) => (total += y?.amount || 0), + 0 + ) + } + const addressTotal = utxos.reduce( + (total, y) => (total += y?.value || 0), + 0 + ) + this.updateAmountForAddress(addressData, addressTotal) + }, + getTotalSelectedUtxoAmount: function () { + const total = this.utxos.data + .filter(u => u.selected) + .reduce((t, a) => t + (a.amount || 0), 0) + return total + }, + applyUtxoSelectionMode: function () { + const payedAmount = this.getTotalPaymentAmount() + const mode = this.payment.utxoSelectionMode + this.utxos.data.forEach(u => (u.selected = false)) + const isManual = mode === 'Manual' + if (isManual || !payedAmount) return + + const isSelectAll = mode === 'Select All' + if (isSelectAll || payedAmount >= this.utxos.total) { + this.utxos.data.forEach(u => (u.selected = true)) + return + } + const isSmallerFirst = mode === 'Smaller Inputs First' + const isLargerFirst = mode === 'Larger Inputs First' + + let selectedUtxos = this.utxos.data.slice() + if (isSmallerFirst || isLargerFirst) { + const sortFn = isSmallerFirst + ? (a, b) => a.amount - b.amount + : (a, b) => b.amount - a.amount + selectedUtxos.sort(sortFn) + } else { + // default to random order + selectedUtxos = _.shuffle(selectedUtxos) + } + selectedUtxos.reduce((total, utxo) => { + utxo.selected = total < payedAmount + total += utxo.amount + return total + }, 0) + }, + + //################### MEMPOOL API ################### + getAddressTxsDelayed: async function (addrData) { + const { + bitcoin: {addresses: addressesAPI} + } = mempoolJS() + + const fn = async () => + addressesAPI.getAddressTxs({ + address: addrData.address + }) + const addressTxs = await retryWithDelay(fn) + return this.addressHistoryFromTxs(addrData, addressTxs) + }, + + refreshRecommendedFees: async function () { + const { + bitcoin: {fees: feesAPI} + } = mempoolJS() + + const fn = async () => feesAPI.getFeesRecommended() + this.payment.recommededFees = await retryWithDelay(fn) + }, + getAddressTxsUtxoDelayed: async function (address) { + const { + bitcoin: {addresses: addressesAPI} + } = mempoolJS() + + const fn = async () => + addressesAPI.getAddressTxsUtxo({ + address + }) + return retryWithDelay(fn) + }, + fetchTxHex: async function (txId) { + const { + bitcoin: {transactions: transactionsAPI} + } = mempoolJS() + + try { + const response = await transactionsAPI.getTxHex({txid: txId}) + return response + } catch (error) { + this.$q.notify({ + type: 'warning', + message: `Failed to fetch transaction details for tx id: '${txId}'`, + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + throw error + } + }, + + //################### OTHER ################### + closeFormDialog: function () { + this.formDialog.data = { + is_unique: false + } + }, + openQrCodeDialog: function (addressData) { + this.currentAddress = addressData + this.addresses.note = addressData.note || '' + this.addresses.show = true + }, + searchInTab: function (tab, value) { + this.tab = tab + this[`${tab}Table`].filter = value + }, + + satBtc(val, showUnit = true) { + const value = this.config.data.sats_denominated + ? LNbits.utils.formatSat(val) + : val == 0 + ? 0.0 + : (val / 100000000).toFixed(8) + if (!showUnit) return value + return this.config.data.sats_denominated ? value + ' sat' : value + ' BTC' + }, + getAccountDescription: function (accountType) { + return getAccountDescription(accountType) + } + }, + created: async function () { + if (this.g.user.wallets.length) { + await this.getConfig() + await this.refreshWalletAccounts() + await this.refreshAddresses() + await this.scanAddressWithAmount() + } + } +}) diff --git a/lnbits/extensions/watchonly/static/js/map.js b/lnbits/extensions/watchonly/static/js/map.js new file mode 100644 index 000000000..d1bc80389 --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/map.js @@ -0,0 +1,80 @@ +const mapAddressesData = a => ({ + id: a.id, + address: a.address, + amount: a.amount, + wallet: a.wallet, + note: a.note, + + isChange: a.branch_index === 1, + addressIndex: a.address_index, + hasActivity: a.has_activity +}) + +const mapInputToSentHistory = (tx, addressData, vin) => ({ + sent: true, + txId: tx.txid, + address: addressData.address, + isChange: addressData.isChange, + amount: vin.prevout.value, + date: blockTimeToDate(tx.status.block_time), + height: tx.status.block_height, + confirmed: tx.status.confirmed, + fee: tx.fee, + expanded: false +}) + +const mapOutputToReceiveHistory = (tx, addressData, vout) => ({ + received: true, + txId: tx.txid, + address: addressData.address, + isChange: addressData.isChange, + amount: vout.value, + date: blockTimeToDate(tx.status.block_time), + height: tx.status.block_height, + confirmed: tx.status.confirmed, + fee: tx.fee, + expanded: false +}) + +const mapUtxoToPsbtInput = utxo => ({ + tx_id: utxo.txId, + vout: utxo.vout, + amount: utxo.amount, + address: utxo.address, + branch_index: utxo.isChange ? 1 : 0, + address_index: utxo.addressIndex, + masterpub_fingerprint: utxo.masterpubFingerprint, + accountType: utxo.accountType, + txHex: '' +}) + +const mapAddressDataToUtxo = (wallet, addressData, utxo) => ({ + id: addressData.id, + address: addressData.address, + isChange: addressData.isChange, + addressIndex: addressData.addressIndex, + wallet: addressData.wallet, + accountType: addressData.accountType, + masterpubFingerprint: wallet.fingerprint, + txId: utxo.txid, + vout: utxo.vout, + confirmed: utxo.status.confirmed, + amount: utxo.value, + date: blockTimeToDate(utxo.status?.block_time), + sort: utxo.status?.block_time, + expanded: false, + selected: false +}) + +const mapWalletAccount = function (obj) { + obj._data = _.clone(obj) + obj.date = obj.time + ? Quasar.utils.date.formatDate( + new Date(obj.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + : '' + obj.label = obj.title // for drop-downs + obj.expanded = false + return obj +} diff --git a/lnbits/extensions/watchonly/static/js/tables.js b/lnbits/extensions/watchonly/static/js/tables.js new file mode 100644 index 000000000..fdd558bde --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/tables.js @@ -0,0 +1,277 @@ +const tables = { + walletsTable: { + columns: [ + { + name: 'new', + align: 'left', + label: '' + }, + { + name: 'title', + align: 'left', + label: 'Title', + field: 'title' + }, + { + name: 'amount', + align: 'left', + label: 'Amount' + }, + { + name: 'type', + align: 'left', + label: 'Type', + field: 'type' + }, + {name: 'id', align: 'left', label: 'ID', field: 'id'} + ], + pagination: { + rowsPerPage: 10 + }, + filter: '' + }, + utxosTable: { + columns: [ + { + name: 'expand', + align: 'left', + label: '' + }, + { + name: 'selected', + align: 'left', + label: '' + }, + { + name: 'status', + align: 'center', + label: 'Status', + sortable: true + }, + { + name: 'address', + align: 'left', + label: 'Address', + field: 'address', + sortable: true + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount', + sortable: true + }, + { + name: 'date', + align: 'left', + label: 'Date', + field: 'date', + sortable: true + }, + { + name: 'wallet', + align: 'left', + label: 'Account', + field: 'wallet', + sortable: true + } + ], + pagination: { + rowsPerPage: 10 + }, + filter: '' + }, + paymentTable: { + columns: [ + { + name: 'data', + align: 'left' + } + ], + pagination: { + rowsPerPage: 10 + }, + filter: '' + }, + summaryTable: { + columns: [ + { + name: 'totalInputs', + align: 'center', + label: 'Selected Amount' + }, + { + name: 'totalOutputs', + align: 'center', + label: 'Payed Amount' + }, + { + name: 'fees', + align: 'center', + label: 'Fees' + }, + { + name: 'change', + align: 'center', + label: 'Change' + } + ] + }, + addressesTable: { + columns: [ + { + name: 'expand', + align: 'left', + label: '' + }, + { + name: 'address', + align: 'left', + label: 'Address', + field: 'address', + sortable: true + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount', + sortable: true + }, + { + name: 'note', + align: 'left', + label: 'Note', + field: 'note', + sortable: true + }, + { + name: 'wallet', + align: 'left', + label: 'Account', + field: 'wallet', + sortable: true + } + ], + pagination: { + rowsPerPage: 0, + sortBy: 'amount', + descending: true + }, + filter: '' + }, + historyTable: { + columns: [ + { + name: 'expand', + align: 'left', + label: '' + }, + { + name: 'status', + align: 'left', + label: 'Status' + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount', + sortable: true + }, + { + name: 'address', + align: 'left', + label: 'Address', + field: 'address', + sortable: true + }, + { + name: 'date', + align: 'left', + label: 'Date', + field: 'date', + sortable: true + } + ], + exportColums: [ + { + label: 'Action', + field: 'action' + }, + { + label: 'Date&Time', + field: 'date' + }, + { + label: 'Amount', + field: 'amount' + }, + { + label: 'Fee', + field: 'fee' + }, + { + label: 'Transaction Id', + field: 'txId' + } + ], + pagination: { + rowsPerPage: 0 + }, + filter: '' + } +} + +const tableData = { + walletAccounts: [], + addresses: { + show: false, + data: [], + history: [], + selectedWallet: null, + note: '', + filterOptions: [ + 'Show Change Addresses', + 'Show Gap Addresses', + 'Only With Amount' + ], + filterValues: [] + }, + utxos: { + data: [], + total: 0 + }, + payment: { + data: [{address: '', amount: undefined}], + changeWallet: null, + changeAddress: {}, + changeAmount: 0, + + feeRate: 1, + recommededFees: { + fastestFee: 1, + halfHourFee: 1, + hourFee: 1, + economyFee: 1, + minimumFee: 1 + }, + fee: 0, + txSize: 0, + psbtBase64: '', + utxoSelectionModes: [ + 'Manual', + 'Random', + 'Select All', + 'Smaller Inputs First', + 'Larger Inputs First' + ], + utxoSelectionMode: 'Manual', + show: false, + showAdvanced: false + }, + summary: { + data: [{totalInputs: 0, totalOutputs: 0, fees: 0, change: 0}] + } +} diff --git a/lnbits/extensions/watchonly/static/js/utils.js b/lnbits/extensions/watchonly/static/js/utils.js new file mode 100644 index 000000000..26bebac66 --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/utils.js @@ -0,0 +1,99 @@ +const blockTimeToDate = blockTime => + blockTime ? moment(blockTime * 1000).format('LLL') : '' + +const currentDateTime = () => moment().format('LLL') + +const sleep = ms => new Promise(r => setTimeout(r, ms)) + +const retryWithDelay = async function (fn, retryCount = 0) { + try { + await sleep(25) + // Do not return the call directly, use result. + // Otherwise the error will not be cought in this try-catch block. + const result = await fn() + return result + } catch (err) { + if (retryCount > 100) throw err + await sleep((retryCount + 1) * 1000) + return retryWithDelay(fn, retryCount + 1) + } +} + +const txSize = tx => { + // https://bitcoinops.org/en/tools/calc-size/ + // overhead size + const nVersion = 4 + const inCount = 1 + const outCount = 1 + const nlockTime = 4 + const hasSegwit = !!tx.inputs.find(inp => + ['p2wsh', 'p2wpkh', 'p2tr'].includes(inp.accountType) + ) + const segwitFlag = hasSegwit ? 0.5 : 0 + const overheadSize = nVersion + inCount + outCount + nlockTime + segwitFlag + + // inputs size + const outpoint = 36 // txId plus vout index number + const scriptSigLength = 1 + const nSequence = 4 + const inputsSize = tx.inputs.reduce((t, inp) => { + const scriptSig = + inp.accountType === 'p2pkh' ? 107 : inp.accountType === 'p2sh' ? 254 : 0 + const witnessItemCount = hasSegwit ? 0.25 : 0 + const witnessItems = + inp.accountType === 'p2wpkh' + ? 27 + : inp.accountType === 'p2wsh' + ? 63.5 + : inp.accountType === 'p2tr' + ? 16.5 + : 0 + t += + outpoint + + scriptSigLength + + nSequence + + scriptSig + + witnessItemCount + + witnessItems + return t + }, 0) + + // outputs size + const nValue = 8 + const scriptPubKeyLength = 1 + + const outputsSize = tx.outputs.reduce((t, out) => { + const type = guessAddressType(out.address) + + const scriptPubKey = + type === 'p2pkh' + ? 25 + : type === 'p2wpkh' + ? 22 + : type === 'p2sh' + ? 23 + : type === 'p2wsh' + ? 34 + : 34 // default to the largest size (p2tr included) + t += nValue + scriptPubKeyLength + scriptPubKey + return t + }, 0) + + return overheadSize + inputsSize + outputsSize +} +const guessAddressType = (a = '') => { + if (a.startsWith('1') || a.startsWith('n')) return 'p2pkh' + if (a.startsWith('3') || a.startsWith('2')) return 'p2sh' + if (a.startsWith('bc1q') || a.startsWith('tb1q')) + return a.length === 42 ? 'p2wpkh' : 'p2wsh' + if (a.startsWith('bc1p') || a.startsWith('tb1p')) return 'p2tr' +} + +const ACCOUNT_TYPES = { + p2tr: 'Taproot, BIP86, P2TR, Bech32m', + p2wpkh: 'SegWit, BIP84, P2WPKH, Bech32', + p2sh: 'BIP49, P2SH-P2WPKH, Base58', + p2pkh: 'Legacy, BIP44, P2PKH, Base58' +} + +const getAccountDescription = type => ACCOUNT_TYPES[type] || 'nonstandard' diff --git a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html index 94b44a443..db0811f57 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html +++ b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html @@ -1,248 +1,27 @@

- Watch Only extension uses mempool.space
+ Onchain Wallet (watch-only) extension uses mempool.space
For use with "account Extended Public Key" https://iancoleman.io/bip39/
Created by, - Ben Arc (using, - Ben Arc + (using, + Embit
) +
+
+ Swagger REST API Documentation

- - - - - - - GET /watchonly/api/v1/wallet -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<wallets_object>, ...] -
Curl example
- curl -X GET {{ request.base_url }}watchonly/api/v1/wallet -H - "X-Api-Key: {{ user.wallets[0].inkey }}" - -
-
-
- - - - GET - /watchonly/api/v1/wallet/<wallet_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 201 CREATED (application/json) -
- [<wallet_object>, ...] -
Curl example
- curl -X GET {{ request.base_url - }}watchonly/api/v1/wallet/<wallet_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" - -
-
-
- - - - POST /watchonly/api/v1/wallet -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 201 CREATED (application/json) -
- [<wallet_object>, ...] -
Curl example
- curl -X POST {{ request.base_url }}watchonly/api/v1/wallet -d - '{"title": <string>, "masterpub": <string>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" - -
-
-
- - - - DELETE - /watchonly/api/v1/wallet/<wallet_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.base_url - }}watchonly/api/v1/wallet/<wallet_id> -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" - -
-
-
- - - - - GET - /watchonly/api/v1/addresses/<wallet_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<address_object>, ...] -
Curl example
- curl -X GET {{ request.base_url - }}watchonly/api/v1/addresses/<wallet_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" - -
-
-
- - - - - GET - /watchonly/api/v1/address/<wallet_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<address_object>, ...] -
Curl example
- curl -X GET {{ request.base_url - }}watchonly/api/v1/address/<wallet_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" - -
-
-
- - - - - GET /watchonly/api/v1/mempool -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<mempool_object>, ...] -
Curl example
- curl -X GET {{ request.base_url }}watchonly/api/v1/mempool -H - "X-Api-Key: {{ user.wallets[0].adminkey }}" - -
-
-
- - - - - POST - /watchonly/api/v1/mempool -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 201 CREATED (application/json) -
- [<mempool_object>, ...] -
Curl example
- curl -X PUT {{ request.base_url }}watchonly/api/v1/mempool -d - '{"endpoint": <string>}' -H "Content-type: application/json" - -H "X-Api-Key: {{ user.wallets[0].adminkey }}" - -
-
-
-
diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html index e70f8a23c..0ab2a67be 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/index.html +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -3,36 +3,36 @@
- - {% raw %} - New wallet - - -
- Point to another Mempool - {{ this.mempool.endpoint }} - - -
- set - cancel -
-
+ {% raw %} +
+
+
+
{{satBtc(utxos.total)}}
- - +
+
+ + +
+
-
Wallets
+ Add Wallet Account +
-
+ +
+
@@ -106,70 +172,862 @@ -
-
{{satBtc(utxos.total)}}
- - {{utxos.sats ? ' sats' : ' BTC'}} +
+
+ Scan Blockchain + +
+
+ Make Payment +
+
+ +
+
-
-
-
Transactions
-
-
- + + + + + + +
+
+ +
+
+ +
+
+ + + +
+
+ -