@@ -200,7 +203,8 @@
type="number"
label="Comment maximum characters"
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
- >
+ >
+
+ >
+
Shareable link
+
+
data.max:
raise HTTPException(
@@ -87,12 +84,18 @@ async def api_link_create_or_update(
)
if data.currency == None and (
- round(data.min) != data.min or round(data.max) != data.max
+ round(data.min) != data.min or round(data.max) != data.max or data.min < 1
):
raise HTTPException(
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
)
+ # database only allows int4 entries for min and max. For fiat currencies,
+ # we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
+ if data.currency and data.fiat_base_multiplier:
+ data.min *= data.fiat_base_multiplier
+ data.max *= data.fiat_base_multiplier
+
if "success_url" in data and data.success_url[:8] != "https://":
raise HTTPException(
detail="Success URL must be secure https://...",
@@ -115,7 +118,7 @@ async def api_link_create_or_update(
link = await update_pay_link(**data.dict(), link_id=link_id)
else:
link = await create_pay_link(data, wallet_id=wallet.wallet.id)
- return {**link.dict(), "lnurl": link.lnurl}
+ return {**link.dict(), "lnurl": link.lnurl(request)}
@lnurlp_ext.delete("/api/v1/links/{link_id}")
diff --git a/lnbits/extensions/lnurlpayout/__init__.py b/lnbits/extensions/lnurlpayout/__init__.py
index 1626e2e5..9962290c 100644
--- a/lnbits/extensions/lnurlpayout/__init__.py
+++ b/lnbits/extensions/lnurlpayout/__init__.py
@@ -1,4 +1,5 @@
import asyncio
+
from fastapi import APIRouter
from lnbits.db import Database
diff --git a/lnbits/extensions/lnurlpayout/config.json.example b/lnbits/extensions/lnurlpayout/config.json.example
index 1e72c0c1..b4160d7b 100644
--- a/lnbits/extensions/lnurlpayout/config.json.example
+++ b/lnbits/extensions/lnurlpayout/config.json.example
@@ -2,5 +2,5 @@
"name": "LNURLPayout",
"short_description": "Autodump wallet funds to LNURLpay",
"icon": "exit_to_app",
- "contributors": ["arcbtc"]
+ "contributors": ["arcbtc","talvasconcelos"]
}
diff --git a/lnbits/extensions/lnurlpayout/crud.py b/lnbits/extensions/lnurlpayout/crud.py
index 6cbf6c54..0f9f98ac 100644
--- a/lnbits/extensions/lnurlpayout/crud.py
+++ b/lnbits/extensions/lnurlpayout/crud.py
@@ -3,7 +3,7 @@ from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
-from .models import lnurlpayout, CreateLnurlPayoutData
+from .models import CreateLnurlPayoutData, lnurlpayout
async def create_lnurlpayout(
diff --git a/lnbits/extensions/lnurlpayout/tasks.py b/lnbits/extensions/lnurlpayout/tasks.py
index 7f2a8324..b621876c 100644
--- a/lnbits/extensions/lnurlpayout/tasks.py
+++ b/lnbits/extensions/lnurlpayout/tasks.py
@@ -2,6 +2,7 @@ import asyncio
from http import HTTPStatus
import httpx
+from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core import db as core_db
@@ -27,7 +28,7 @@ async def on_invoice_paid(payment: Payment) -> None:
try:
# Check its got a payout associated with it
lnurlpayout_link = await get_lnurlpayout_from_wallet(payment.wallet_id)
- print("LNURLpayout", lnurlpayout_link)
+ logger.debug("LNURLpayout", lnurlpayout_link)
if lnurlpayout_link:
# Check the wallet balance is more than the threshold
diff --git a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html
index 7febea44..afe24c42 100644
--- a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html
+++ b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html
@@ -4,6 +4,7 @@
label="API info"
:content-inset-level="0.5"
>
+
diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py
index a601c1b8..0b776a8c 100644
--- a/lnbits/extensions/offlineshop/__init__.py
+++ b/lnbits/extensions/offlineshop/__init__.py
@@ -9,7 +9,7 @@ db = Database("ext_offlineshop")
offlineshop_static_files = [
{
"path": "/offlineshop/static",
- "app": StaticFiles(directory="lnbits/extensions/offlineshop/static"),
+ "app": StaticFiles(packages=[("lnbits", "extensions/offlineshop/static")]),
"name": "offlineshop_static",
}
]
diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py
index 0ecb3d52..bc4f3f14 100644
--- a/lnbits/extensions/offlineshop/crud.py
+++ b/lnbits/extensions/offlineshop/crud.py
@@ -1,9 +1,10 @@
from typing import List, Optional
from lnbits.db import SQLITE
+
from . import db
+from .models import Item, Shop
from .wordlists import animals
-from .models import Shop, Item
async def create_shop(*, wallet_id: str) -> int:
diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py
index db2c19cc..86a653aa 100644
--- a/lnbits/extensions/offlineshop/helpers.py
+++ b/lnbits/extensions/offlineshop/helpers.py
@@ -1,6 +1,6 @@
import base64
-import struct
import hmac
+import struct
import time
@@ -8,8 +8,8 @@ def hotp(key, counter, digits=6, digest="sha1"):
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
counter = struct.pack(">Q", counter)
mac = hmac.new(key, counter, digest).digest()
- offset = mac[-1] & 0x0f
- binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7fffffff
+ offset = mac[-1] & 0x0F
+ binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
return str(binary)[-digits:].zfill(digits)
diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py
index 06225351..0128fdb8 100644
--- a/lnbits/extensions/offlineshop/models.py
+++ b/lnbits/extensions/offlineshop/models.py
@@ -1,14 +1,15 @@
-import json
import base64
import hashlib
+import json
from collections import OrderedDict
+from typing import Dict, List, Optional
-from typing import Optional, List, Dict
from lnurl import encode as lnurl_encode # type: ignore
-from lnurl.types import LnurlPayMetadata # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
+from lnurl.types import LnurlPayMetadata # type: ignore
from pydantic import BaseModel
from starlette.requests import Request
+
from .helpers import totp
shop_counters: Dict = {}
diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html
index ac655697..0a4b9df8 100644
--- a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html
+++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html
@@ -47,6 +47,7 @@
label="API info"
:content-inset-level="0.5"
>
+
Curl example
curl -X GET {{ request.base_url
- }}/offlineshop/api/v1/offlineshop/items -H "Content-Type:
+ }}offlineshop/api/v1/offlineshop/items -H "Content-Type:
application/json" -H "X-Api-Key: {{ user.wallets[0].inkey }}" -d
'{"name": <string>, "description": <string>, "image":
<data-uri string>, "price": <integer>, "unit": <"sat"
@@ -96,7 +97,7 @@
>
Curl example
curl -X GET {{ request.base_url }}/offlineshop/api/v1/offlineshop -H
+ >curl -X GET {{ request.base_url }}offlineshop/api/v1/offlineshop -H
"X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -118,7 +119,7 @@
Curl example
curl -X GET {{ request.base_url
- }}/offlineshop/api/v1/offlineshop/items/<item_id> -H
+ }}offlineshop/api/v1/offlineshop/items/<item_id> -H
"Content-Type: application/json" -H "X-Api-Key: {{
user.wallets[0].inkey }}" -d '{"name": <string>, "description":
<string>, "image": <data-uri string>, "price":
@@ -127,7 +128,13 @@
-
+
DELETE
@@ -138,7 +145,7 @@
Curl example
curl -X GET {{ request.base_url
- }}/offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key:
+ }}offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key:
{{ user.wallets[0].inkey }}"
diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py
index e1d3a66e..34bb7a03 100644
--- a/lnbits/extensions/offlineshop/views.py
+++ b/lnbits/extensions/offlineshop/views.py
@@ -3,18 +3,18 @@ from datetime import datetime
from http import HTTPStatus
from typing import List
+from fastapi import HTTPException, Request
from fastapi.params import Depends, Query
from starlette.responses import HTMLResponse
-from lnbits.decorators import check_user_exists
-from lnbits.core.models import Payment, User
from lnbits.core.crud import get_standalone_payment
+from lnbits.core.models import Payment, User
from lnbits.core.views.api import api_payment
+from lnbits.decorators import check_user_exists
from . import offlineshop_ext, offlineshop_renderer
-from .models import Item
from .crud import get_item, get_shop
-from fastapi import Request, HTTPException
+from .models import Item
@offlineshop_ext.get("/", response_class=HTMLResponse)
diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html
index ceadf2f0..0fd8bdd3 100644
--- a/lnbits/extensions/paywall/templates/paywall/_api_docs.html
+++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html
@@ -4,6 +4,7 @@
label="API info"
:content-inset-level="0.5"
>
+
@@ -17,8 +18,8 @@
[<paywall_object>, ...]
Curl example
curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{
- user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}paywall/api/v1/paywalls -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -48,11 +49,11 @@
>
Curl example
curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url":
- <string>, "memo": <string>, "description": <string>,
- "amount": <integer>, "remembers": <boolean>}' -H
- "Content-type: application/json" -H "X-Api-Key: {{
- user.wallets[0].adminkey }}"
+ >curl -X POST {{ request.base_url }}paywall/api/v1/paywalls -d
+ '{"url": <string>, "memo": <string>, "description":
+ <string>, "amount": <integer>, "remembers":
+ <boolean>}' -H "Content-type: application/json" -H "X-Api-Key:
+ {{ user.wallets[0].adminkey }}"
@@ -80,8 +81,8 @@
>
Curl example
curl -X POST {{ request.url_root
- }}api/v1/paywalls/<paywall_id>/invoice -d '{"amount":
+ >curl -X POST {{ request.base_url
+ }}paywall/api/v1/paywalls/<paywall_id>/invoice -d '{"amount":
<integer>}' -H "Content-type: application/json"
@@ -111,8 +112,8 @@
>
Curl example
curl -X POST {{ request.url_root
- }}api/v1/paywalls/<paywall_id>/check_invoice -d
+ >curl -X POST {{ request.base_url
+ }}paywall/api/v1/paywalls/<paywall_id>/check_invoice -d
'{"payment_hash": <string>}' -H "Content-type: application/json"
@@ -137,8 +138,8 @@
Curl example
curl -X DELETE {{ request.url_root
- }}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{
+ >curl -X DELETE {{ request.base_url
+ }}paywall/api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py
index 3d1c2575..8052c63b 100644
--- a/lnbits/extensions/paywall/views_api.py
+++ b/lnbits/extensions/paywall/views_api.py
@@ -54,8 +54,7 @@ async def api_paywall_delete(
@paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}")
async def api_paywall_create_invoice(
- data: CreatePaywallInvoice,
- paywall_id: str = Query(None)
+ data: CreatePaywallInvoice, paywall_id: str = Query(None)
):
paywall = await get_paywall(paywall_id)
if data.amount < paywall.amount:
@@ -78,7 +77,9 @@ async def api_paywall_create_invoice(
@paywall_ext.post("/api/v1/paywalls/check_invoice/{paywall_id}")
-async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id: str = Query(None)):
+async def api_paywal_check_invoice(
+ data: CheckPaywallInvoice, paywall_id: str = Query(None)
+):
paywall = await get_paywall(paywall_id)
payment_hash = data.payment_hash
if not paywall:
diff --git a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html
index fb43b90d..e85e9586 100644
--- a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html
+++ b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html
@@ -4,6 +4,7 @@
label="API info"
:content-inset-level="0.5"
>
+
@@ -17,8 +18,8 @@
[<satsdice_link_object>, ...]
Curl example
curl -X GET {{ request.base_url }}api/v1/links -H "X-Api-Key: {{
- user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}satsdice/api/v1/links -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -44,8 +45,9 @@
{"lnurl": <string>}
Curl example
curl -X GET {{ request.base_url }}api/v1/links/<satsdice_id> -H
- "X-Api-Key: {{ user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url
+ }}satsdice/api/v1/links/<satsdice_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -73,8 +75,8 @@
{"lnurl": <string>}
Curl example
curl -X POST {{ request.base_url }}api/v1/links -d '{"title":
- <string>, "min_satsdiceable": <integer>,
+ >curl -X POST {{ request.base_url }}satsdice/api/v1/links -d
+ '{"title": <string>, "min_satsdiceable": <integer>,
"max_satsdiceable": <integer>, "uses": <integer>,
"wait_time": <integer>, "is_unique": <boolean>}' -H
"Content-type: application/json" -H "X-Api-Key: {{
@@ -109,8 +111,9 @@
{"lnurl": <string>}
Curl example
curl -X PUT {{ request.base_url }}api/v1/links/<satsdice_id> -d
- '{"title": <string>, "min_satsdiceable": <integer>,
+ >curl -X PUT {{ request.base_url
+ }}satsdice/api/v1/links/<satsdice_id> -d '{"title":
+ <string>, "min_satsdiceable": <integer>,
"max_satsdiceable": <integer>, "uses": <integer>,
"wait_time": <integer>, "is_unique": <boolean>}' -H
"Content-type: application/json" -H "X-Api-Key: {{
@@ -137,8 +140,9 @@
Curl example
curl -X DELETE {{ request.base_url }}api/v1/links/<satsdice_id>
- -H "X-Api-Key: {{ user.wallets[0].adminkey }}"
+ >curl -X DELETE {{ request.base_url
+ }}satsdice/api/v1/links/<satsdice_id> -H "X-Api-Key: {{
+ user.wallets[0].adminkey }}"
@@ -165,8 +169,8 @@
Curl example
curl -X GET {{ request.base_url
- }}api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{
- user.wallets[0].inkey }}"
+ }}satsdice/api/v1/links/<the_hash>/<lnurl_id> -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -186,7 +190,7 @@
>
Curl example
curl -X GET {{ request.base_url }}/satsdice/img/<lnurl_id>"
+ >curl -X GET {{ request.base_url }}satsdice/img/<lnurl_id>"
diff --git a/lnbits/extensions/satspay/tasks.py b/lnbits/extensions/satspay/tasks.py
index 87f74b7d..d325405b 100644
--- a/lnbits/extensions/satspay/tasks.py
+++ b/lnbits/extensions/satspay/tasks.py
@@ -1,5 +1,7 @@
import asyncio
+from loguru import logger
+
from lnbits.core.models import Payment
from lnbits.extensions.satspay.crud import check_address_balance, get_charge
from lnbits.tasks import register_invoice_listener
@@ -17,13 +19,13 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
- if "charge" != payment.extra.get("tag"):
+ if payment.extra.get("tag") != "charge":
# not a charge invoice
return
charge = await get_charge(payment.memo)
if not charge:
- print("this should never happen", payment)
+ logger.error("this should never happen", payment)
return
await payment.set_pending(False)
diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html
index d834db20..336ab899 100644
--- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html
+++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html
@@ -15,6 +15,7 @@
label="API info"
:content-inset-level="0.5"
>
+
@@ -32,7 +33,7 @@
[<charge_object>, ...]
Curl example
curl -X POST {{ request.base_url }}api/v1/charge -d
+ >curl -X POST {{ request.base_url }}satspay/api/v1/charge -d
'{"onchainwallet": <string, watchonly_wallet_id>,
"description": <string>, "webhook":<string>, "time":
<integer>, "amount": <integer>, "lnbitswallet":
@@ -60,12 +61,13 @@
[<charge_object>, ...]
Curl example
curl -X POST {{ request.base_url }}api/v1/charge/<charge_id>
- -d '{"onchainwallet": <string, watchonly_wallet_id>,
- "description": <string>, "webhook":<string>, "time":
- <integer>, "amount": <integer>, "lnbitswallet":
- <string, lnbits_wallet_id>}' -H "Content-type:
- application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}"
+ >curl -X POST {{ request.base_url
+ }}satspay/api/v1/charge/<charge_id> -d '{"onchainwallet":
+ <string, watchonly_wallet_id>, "description": <string>,
+ "webhook":<string>, "time": <integer>, "amount":
+ <integer>, "lnbitswallet": <string, lnbits_wallet_id>}'
+ -H "Content-type: application/json" -H "X-Api-Key:
+ {{user.wallets[0].adminkey }}"
@@ -89,8 +91,9 @@
[<charge_object>, ...]
Curl example
curl -X GET {{ request.base_url }}api/v1/charge/<charge_id>
- -H "X-Api-Key: {{ user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url
+ }}satspay/api/v1/charge/<charge_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -112,8 +115,8 @@
[<charge_object>, ...]
Curl example
curl -X GET {{ request.base_url }}api/v1/charges -H "X-Api-Key: {{
- user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}satspay/api/v1/charges -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -123,7 +126,6 @@
dense
expand-separator
label="Delete a pay link"
- class="q-pb-md"
>
@@ -138,13 +140,19 @@
Curl example
curl -X DELETE {{ request.base_url
- }}api/v1/charge/<charge_id> -H "X-Api-Key: {{
+ }}satspay/api/v1/charge/<charge_id> -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
-
+
Curl example
curl -X GET {{ request.base_url
- }}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{
+ }}satspay/api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{
user.wallets[0].inkey }}"
diff --git a/lnbits/extensions/splitpayments/__init__.py b/lnbits/extensions/splitpayments/__init__.py
index df6feb94..9989728e 100644
--- a/lnbits/extensions/splitpayments/__init__.py
+++ b/lnbits/extensions/splitpayments/__init__.py
@@ -12,7 +12,7 @@ db = Database("ext_splitpayments")
splitpayments_static_files = [
{
"path": "/splitpayments/static",
- "app": StaticFiles(directory="lnbits/extensions/splitpayments/static"),
+ "app": StaticFiles(packages=[("lnbits", "extensions/splitpayments/static")]),
"name": "splitpayments_static",
}
]
diff --git a/lnbits/extensions/splitpayments/migrations.py b/lnbits/extensions/splitpayments/migrations.py
index 735afc6c..b3921c42 100644
--- a/lnbits/extensions/splitpayments/migrations.py
+++ b/lnbits/extensions/splitpayments/migrations.py
@@ -14,3 +14,41 @@ async def m001_initial(db):
);
"""
)
+
+
+async def m002_float_percent(db):
+ """
+ Add float percent and migrates the existing data.
+ """
+ await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_old")
+ await db.execute(
+ """
+ CREATE TABLE splitpayments.targets (
+ wallet TEXT NOT NULL,
+ source TEXT NOT NULL,
+ percent REAL NOT NULL CHECK (percent >= 0 AND percent <= 100),
+ alias TEXT,
+
+ UNIQUE (source, wallet)
+ );
+ """
+ )
+
+ for row in [
+ list(row)
+ for row in await db.fetchall("SELECT * FROM splitpayments.splitpayments_old")
+ ]:
+ await db.execute(
+ """
+ INSERT INTO splitpayments.targets (
+ wallet,
+ source,
+ percent,
+ alias
+ )
+ VALUES (?, ?, ?, ?)
+ """,
+ (row[0], row[1], row[2], row[3]),
+ )
+
+ await db.execute("DROP TABLE splitpayments.splitpayments_old")
diff --git a/lnbits/extensions/splitpayments/models.py b/lnbits/extensions/splitpayments/models.py
index 3264bca7..4b95ed18 100644
--- a/lnbits/extensions/splitpayments/models.py
+++ b/lnbits/extensions/splitpayments/models.py
@@ -7,14 +7,14 @@ from pydantic import BaseModel
class Target(BaseModel):
wallet: str
source: str
- percent: int
+ percent: float
alias: Optional[str]
class TargetPutList(BaseModel):
wallet: str = Query(...)
alias: str = Query("")
- percent: int = Query(..., ge=1)
+ percent: float = Query(..., ge=0.01)
class TargetPut(BaseModel):
diff --git a/lnbits/extensions/splitpayments/static/js/index.js b/lnbits/extensions/splitpayments/static/js/index.js
index dea469e5..5d326231 100644
--- a/lnbits/extensions/splitpayments/static/js/index.js
+++ b/lnbits/extensions/splitpayments/static/js/index.js
@@ -105,7 +105,7 @@ new Vue({
if (currentTotal > 100 && isPercent) {
let diff = (currentTotal - 100) / (100 - this.targets[index].percent)
this.targets.forEach((target, t) => {
- if (t !== index) target.percent -= Math.round(diff * target.percent)
+ if (t !== index) target.percent -= +(diff * target.percent).toFixed(2)
})
}
@@ -119,7 +119,7 @@ new Vue({
'/splitpayments/api/v1/targets',
this.selectedWallet.adminkey,
{
- "targets": this.targets
+ targets: this.targets
.filter(isTargetComplete)
.map(({wallet, percent, alias}) => ({wallet, percent, alias}))
}
diff --git a/lnbits/extensions/splitpayments/tasks.py b/lnbits/extensions/splitpayments/tasks.py
index c54c067f..0948e849 100644
--- a/lnbits/extensions/splitpayments/tasks.py
+++ b/lnbits/extensions/splitpayments/tasks.py
@@ -1,6 +1,8 @@
import asyncio
import json
+from loguru import logger
+
from lnbits.core import db as core_db
from lnbits.core.crud import create_payment
from lnbits.core.models import Payment
@@ -20,7 +22,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
- if "splitpayments" == payment.extra.get("tag") or payment.extra.get("splitted"):
+ if payment.extra.get("tag") == "splitpayments" or payment.extra.get("splitted"):
# already splitted, ignore
return
@@ -34,7 +36,9 @@ async def on_invoice_paid(payment: Payment) -> None:
amount_left = payment.amount - sum([amount for _, amount in transfers])
if amount_left < 0:
- print("splitpayments failure: amount_left is negative.", payment.payment_hash)
+ logger.error(
+ "splitpayments failure: amount_left is negative.", payment.payment_hash
+ )
return
if not targets:
diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html
index 78b5362c..4b5ed979 100644
--- a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html
+++ b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html
@@ -28,6 +28,12 @@
label="API info"
:content-inset-level="0.5"
>
+
Curl example
curl -X GET {{ request.base_url }}api/v1/livestream -H "X-Api-Key: {{
- user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}splitpayments/api/v1/targets -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -63,6 +69,7 @@
dense
expand-separator
label="Set Target Wallets"
+ class="q-pb-md"
>
@@ -78,7 +85,7 @@
Curl example
curl -X PUT {{ request.base_url }}api/v1/splitpayments/targets -H
+ >curl -X PUT {{ request.base_url }}splitpayments/api/v1/targets -H
"X-Api-Key: {{ user.wallets[0].adminkey }}" -H 'Content-Type:
application/json' -d '{"targets": [{"wallet": <wallet id or invoice
key>, "alias": <name to identify this>, "percent": <number
diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/index.html b/lnbits/extensions/splitpayments/templates/splitpayments/index.html
index 1aae4e33..5862abc1 100644
--- a/lnbits/extensions/splitpayments/templates/splitpayments/index.html
+++ b/lnbits/extensions/splitpayments/templates/splitpayments/index.html
@@ -58,14 +58,14 @@
>
-
-
+
+
Clear
-
+
-
+
Save Targets
-
-
+
+
diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html b/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html
index 33b52f15..346ab4ec 100644
--- a/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html
+++ b/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html
@@ -15,4 +15,5 @@
>
+
diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py
index 0a678d8b..bb2998ee 100644
--- a/lnbits/extensions/streamalerts/views_api.py
+++ b/lnbits/extensions/streamalerts/views_api.py
@@ -123,7 +123,7 @@ async def api_create_donation(data: CreateDonation, request: Request):
completelinktext="Back to Stream!",
webhook=webhook_base + "/streamalerts/api/v1/postdonation",
description=description,
- **charge_details
+ **charge_details,
)
charge = await create_charge(user=charge_details["user"], data=create_charge_data)
await create_donation(
diff --git a/lnbits/extensions/subdomains/cloudflare.py b/lnbits/extensions/subdomains/cloudflare.py
index 8ada2a90..679ca843 100644
--- a/lnbits/extensions/subdomains/cloudflare.py
+++ b/lnbits/extensions/subdomains/cloudflare.py
@@ -1,5 +1,8 @@
+import json
+
+import httpx
+
from lnbits.extensions.subdomains.models import Domains
-import httpx, json
async def cloudflare_create_subdomain(
diff --git a/lnbits/extensions/subdomains/tasks.py b/lnbits/extensions/subdomains/tasks.py
index 75223e82..d8f35161 100644
--- a/lnbits/extensions/subdomains/tasks.py
+++ b/lnbits/extensions/subdomains/tasks.py
@@ -19,7 +19,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
- if "lnsubdomain" != payment.extra.get("tag"):
+ if payment.extra.get("tag") != "lnsubdomain":
# not an lnurlp invoice
return
diff --git a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html
index b839c641..db3b2477 100644
--- a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html
+++ b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html
@@ -22,5 +22,6 @@
>
+
diff --git a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html
index 42788bad..cfb8136b 100644
--- a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html
+++ b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html
@@ -4,13 +4,13 @@
Tip Jar: Receive tips with messages!
- Your personal Bitcoin tip page, which supports
- lightning and on-chain payments.
- Notifications, including a donation message,
- can be sent via webhook.
+ Your personal Bitcoin tip page, which supports lightning and on-chain
+ payments. Notifications, including a donation message, can be sent via
+ webhook.
Created by, Fitti
+
diff --git a/lnbits/extensions/tipjar/templates/tipjar/index.html b/lnbits/extensions/tipjar/templates/tipjar/index.html
index dda49842..19fca6e4 100644
--- a/lnbits/extensions/tipjar/templates/tipjar/index.html
+++ b/lnbits/extensions/tipjar/templates/tipjar/index.html
@@ -322,11 +322,7 @@
var self = this
LNbits.api
- .request(
- 'GET',
- '/tipjar/api/v1/tips',
- this.g.user.wallets[0].inkey
- )
+ .request('GET', '/tipjar/api/v1/tips', this.g.user.wallets[0].inkey)
.then(function (response) {
self.tips = response.data.map(function (obj) {
return mapTipJar(obj)
diff --git a/lnbits/extensions/tpos/__init__.py b/lnbits/extensions/tpos/__init__.py
index c62981d7..3ce618aa 100644
--- a/lnbits/extensions/tpos/__init__.py
+++ b/lnbits/extensions/tpos/__init__.py
@@ -1,7 +1,10 @@
+import asyncio
+
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
db = Database("ext_tpos")
@@ -12,5 +15,11 @@ def tpos_renderer():
return template_renderer(["lnbits/extensions/tpos/templates"])
-from .views_api import * # noqa
+from .tasks import wait_for_paid_invoices
from .views import * # noqa
+from .views_api import * # noqa
+
+
+def tpos_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/tpos/config.json b/lnbits/extensions/tpos/config.json
index c5789afb..3bd1a71a 100644
--- a/lnbits/extensions/tpos/config.json
+++ b/lnbits/extensions/tpos/config.json
@@ -2,5 +2,5 @@
"name": "TPoS",
"short_description": "A shareable PoS terminal!",
"icon": "dialpad",
- "contributors": ["talvasconcelos", "arcbtc"]
+ "contributors": ["talvasconcelos", "arcbtc", "leesalminen"]
}
diff --git a/lnbits/extensions/tpos/crud.py b/lnbits/extensions/tpos/crud.py
index 1a198769..94e2c006 100644
--- a/lnbits/extensions/tpos/crud.py
+++ b/lnbits/extensions/tpos/crud.py
@@ -10,10 +10,17 @@ async def create_tpos(wallet_id: str, data: CreateTposData) -> TPoS:
tpos_id = urlsafe_short_hash()
await db.execute(
"""
- INSERT INTO tpos.tposs (id, wallet, name, currency)
- VALUES (?, ?, ?, ?)
+ INSERT INTO tpos.tposs (id, wallet, name, currency, tip_options, tip_wallet)
+ VALUES (?, ?, ?, ?, ?, ?)
""",
- (tpos_id, wallet_id, data.name, data.currency),
+ (
+ tpos_id,
+ wallet_id,
+ data.name,
+ data.currency,
+ data.tip_options,
+ data.tip_wallet,
+ ),
)
tpos = await get_tpos(tpos_id)
@@ -23,7 +30,7 @@ async def create_tpos(wallet_id: str, data: CreateTposData) -> TPoS:
async def get_tpos(tpos_id: str) -> Optional[TPoS]:
row = await db.fetchone("SELECT * FROM tpos.tposs WHERE id = ?", (tpos_id,))
- return TPoS.from_row(row) if row else None
+ return TPoS(**row) if row else None
async def get_tposs(wallet_ids: Union[str, List[str]]) -> List[TPoS]:
@@ -35,7 +42,7 @@ async def get_tposs(wallet_ids: Union[str, List[str]]) -> List[TPoS]:
f"SELECT * FROM tpos.tposs WHERE wallet IN ({q})", (*wallet_ids,)
)
- return [TPoS.from_row(row) for row in rows]
+ return [TPoS(**row) for row in rows]
async def delete_tpos(tpos_id: str) -> None:
diff --git a/lnbits/extensions/tpos/migrations.py b/lnbits/extensions/tpos/migrations.py
index 7a7fff0d..565c05ab 100644
--- a/lnbits/extensions/tpos/migrations.py
+++ b/lnbits/extensions/tpos/migrations.py
@@ -12,3 +12,25 @@ async def m001_initial(db):
);
"""
)
+
+
+async def m002_addtip_wallet(db):
+ """
+ Add tips to tposs table
+ """
+ await db.execute(
+ """
+ ALTER TABLE tpos.tposs ADD tip_wallet TEXT NULL;
+ """
+ )
+
+
+async def m003_addtip_options(db):
+ """
+ Add tips to tposs table
+ """
+ await db.execute(
+ """
+ ALTER TABLE tpos.tposs ADD tip_options TEXT NULL;
+ """
+ )
diff --git a/lnbits/extensions/tpos/models.py b/lnbits/extensions/tpos/models.py
index 653a055c..36bca79b 100644
--- a/lnbits/extensions/tpos/models.py
+++ b/lnbits/extensions/tpos/models.py
@@ -1,11 +1,15 @@
from sqlite3 import Row
+from typing import Optional
+from fastapi import Query
from pydantic import BaseModel
class CreateTposData(BaseModel):
name: str
currency: str
+ tip_options: str = Query(None)
+ tip_wallet: str = Query(None)
class TPoS(BaseModel):
@@ -13,6 +17,8 @@ class TPoS(BaseModel):
wallet: str
name: str
currency: str
+ tip_options: Optional[str]
+ tip_wallet: Optional[str]
@classmethod
def from_row(cls, row: Row) -> "TPoS":
diff --git a/lnbits/extensions/tpos/tasks.py b/lnbits/extensions/tpos/tasks.py
new file mode 100644
index 00000000..af9663cc
--- /dev/null
+++ b/lnbits/extensions/tpos/tasks.py
@@ -0,0 +1,70 @@
+import asyncio
+import json
+
+from lnbits.core import db as core_db
+from lnbits.core.crud import create_payment
+from lnbits.core.models import Payment
+from lnbits.helpers import urlsafe_short_hash
+from lnbits.tasks import internal_invoice_queue, register_invoice_listener
+
+from .crud import get_tpos
+
+
+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:
+ if payment.extra.get("tag") == "tpos" and payment.extra.get("tipSplitted"):
+ # already splitted, ignore
+ return
+
+ # now we make some special internal transfers (from no one to the receiver)
+ tpos = await get_tpos(payment.extra.get("tposId"))
+ tipAmount = payment.extra.get("tipAmount")
+
+ if tipAmount is None:
+ # no tip amount
+ return
+
+ tipAmount = tipAmount * 1000
+ amount = payment.amount - tipAmount
+
+ # mark the original payment with one extra key, "splitted"
+ # (this prevents us from doing this process again and it's informative)
+ # and reduce it by the amount we're going to send to the producer
+ await core_db.execute(
+ """
+ UPDATE apipayments
+ SET extra = ?, amount = ?
+ WHERE hash = ?
+ AND checking_id NOT LIKE 'internal_%'
+ """,
+ (
+ json.dumps(dict(**payment.extra, tipSplitted=True)),
+ amount,
+ payment.payment_hash,
+ ),
+ )
+
+ # perform the internal transfer using the same payment_hash
+ internal_checking_id = f"internal_{urlsafe_short_hash()}"
+ await create_payment(
+ wallet_id=tpos.tip_wallet,
+ checking_id=internal_checking_id,
+ payment_request="",
+ payment_hash=payment.payment_hash,
+ amount=tipAmount,
+ memo=f"Tip for {payment.memo}",
+ pending=False,
+ extra={"tipSplitted": True},
+ )
+
+ # manually send this for now
+ await internal_invoice_queue.put(internal_checking_id)
+ return
diff --git a/lnbits/extensions/tpos/templates/tpos/_api_docs.html b/lnbits/extensions/tpos/templates/tpos/_api_docs.html
index 7897383d..cbb21be1 100644
--- a/lnbits/extensions/tpos/templates/tpos/_api_docs.html
+++ b/lnbits/extensions/tpos/templates/tpos/_api_docs.html
@@ -4,6 +4,7 @@
label="API info"
:content-inset-level="0.5"
>
+
@@ -17,7 +18,7 @@
[<tpos_object>, ...]
Curl example
curl -X GET {{ request.base_url }}api/v1/tposs -H "X-Api-Key:
+ >curl -X GET {{ request.base_url }}tpos/api/v1/tposs -H "X-Api-Key:
<invoice_key>"
@@ -42,7 +43,7 @@
>
Curl example
curl -X POST {{ request.base_url }}api/v1/tposs -d '{"name":
+ >curl -X POST {{ request.base_url }}tpos/api/v1/tposs -d '{"name":
<string>, "currency": <string>}' -H "Content-type:
application/json" -H "X-Api-Key: <admin_key>"
@@ -69,8 +70,8 @@
Curl example
curl -X DELETE {{ request.base_url }}api/v1/tposs/<tpos_id> -H
- "X-Api-Key: <admin_key>"
+ >curl -X DELETE {{ request.base_url
+ }}tpos/api/v1/tposs/<tpos_id> -H "X-Api-Key: <admin_key>"
diff --git a/lnbits/extensions/tpos/templates/tpos/index.html b/lnbits/extensions/tpos/templates/tpos/index.html
index a8971211..edbb2aa8 100644
--- a/lnbits/extensions/tpos/templates/tpos/index.html
+++ b/lnbits/extensions/tpos/templates/tpos/index.html
@@ -54,7 +54,8 @@
>
- {{ col.value }}
+ {{ (col.name == 'tip_options' && col.value ?
+ JSON.parse(col.value).join(", ") : col.value) }}
+
+
parseInt(str))
+ )
+ : JSON.stringify([]),
+ tip_wallet: this.formDialog.data.tip_wallet || ''
}
var self = this
diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html
index d05fab4e..ebc6595e 100644
--- a/lnbits/extensions/tpos/templates/tpos/tpos.html
+++ b/lnbits/extensions/tpos/templates/tpos/tpos.html
@@ -1,5 +1,13 @@
-{% extends "public.html" %} {% block toolbar_title %}{{ tpos.name }}{% endblock
-%} {% block footer %}{% endblock %} {% block page_container %}
+{% extends "public.html" %} {% block toolbar_title %} {{ tpos.name }}
+
+{% endblock %} {% block footer %}{% endblock %} {% block page_container %}
@@ -16,66 +24,125 @@
- 1
- 2
- 3
C
- 4
- 5
- 6
- 7
- 8
- 9
OK 5
+ 6
+ 7
+ 8
+ 9
DEL
- 0
# C
+ OK
@@ -100,7 +167,12 @@
{% raw %}{{ famount }}{% endraw %}
- {% raw %}{{ fsat }}{% endraw %} sat
+ {% raw %}{{ fsat }}
+ sat
+ ( + {{ tipAmountSat }} tip)
+ {% endraw %}
@@ -108,6 +180,35 @@
+
+
+
+
+ Would you like to leave a tip?
+
+
+ {% raw %}{{ tip }}{% endraw %}%
+
+
+
+ Close
+
+
+
+
@@ -140,24 +241,29 @@
transition-show="fade"
class="text-light-green"
style="font-size: 40em"
- >
+ >
+
{% endblock %} {% block styles %}
{% endblock %} {% block scripts %}
@@ -171,14 +277,19 @@
return {
tposId: '{{ tpos.id }}',
currency: '{{ tpos.currency }}',
+ tip_options: null,
exchangeRate: null,
stack: [],
+ tipAmount: 0.0,
invoiceDialog: {
show: false,
data: null,
dismissMsg: null,
paymentChecker: null
},
+ tipDialog: {
+ show: false
+ },
urlDialog: {
show: false
},
@@ -197,30 +308,71 @@
},
sat: function () {
if (!this.exchangeRate) return 0
- return Math.ceil((this.amount / this.exchangeRate) * 100000000)
+ return Math.ceil(
+ ((this.amount - this.tipAmount) / this.exchangeRate) * 100000000
+ )
+ },
+ tipAmountSat: function () {
+ if (!this.exchangeRate) return 0
+ return Math.ceil((this.tipAmount / this.exchangeRate) * 100000000)
},
fsat: function () {
- console.log('sat', this.sat, LNbits.utils.formatSat(this.sat))
return LNbits.utils.formatSat(this.sat)
}
},
methods: {
closeInvoiceDialog: function () {
this.stack = []
+ this.tipAmount = 0.0
var dialog = this.invoiceDialog
setTimeout(function () {
clearInterval(dialog.paymentChecker)
dialog.dismissMsg()
}, 3000)
},
+ processTipSelection: function (selectedTipOption) {
+ this.tipDialog.show = false
+
+ if (selectedTipOption) {
+ const tipAmount = parseFloat(
+ parseFloat((selectedTipOption / 100) * this.amount)
+ )
+ const subtotal = parseFloat(this.amount)
+ const grandTotal = parseFloat((tipAmount + subtotal).toFixed(2))
+ const totalString = grandTotal.toFixed(2).toString()
+
+ this.stack = []
+ for (var i = 0; i < totalString.length; i++) {
+ const char = totalString[i]
+
+ if (char !== '.') {
+ this.stack.push(char)
+ }
+ }
+
+ this.tipAmount = tipAmount
+ }
+
+ this.showInvoice()
+ },
+ submitForm: function () {
+ if (this.tip_options) {
+ this.showTipModal()
+ } else {
+ this.showInvoice()
+ }
+ },
+ showTipModal: function () {
+ this.tipDialog.show = true
+ },
showInvoice: function () {
var self = this
var dialog = this.invoiceDialog
- console.log(this.sat, this.tposId)
axios
.post('/tpos/api/v1/tposs/' + this.tposId + '/invoices', null, {
params: {
- amount: this.sat
+ amount: this.sat,
+ tipAmount: this.tipAmountSat
}
})
.then(function (response) {
@@ -269,9 +421,13 @@
created: function () {
var getRates = this.getRates
getRates()
+ this.tip_options =
+ '{{ tpos.tip_options | tojson }}' == 'null'
+ ? null
+ : JSON.parse('{{ tpos.tip_options }}')
setInterval(function () {
getRates()
- }, 20000)
+ }, 120000)
}
})
diff --git a/lnbits/extensions/tpos/views.py b/lnbits/extensions/tpos/views.py
index 2d78ecce..e1f1d21e 100644
--- a/lnbits/extensions/tpos/views.py
+++ b/lnbits/extensions/tpos/views.py
@@ -8,6 +8,7 @@ from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
+from lnbits.settings import LNBITS_CUSTOM_LOGO, LNBITS_SITE_TITLE
from . import tpos_ext, tpos_renderer
from .crud import get_tpos
@@ -31,5 +32,47 @@ async def tpos(request: Request, tpos_id):
)
return tpos_renderer().TemplateResponse(
- "tpos/tpos.html", {"request": request, "tpos": tpos}
+ "tpos/tpos.html",
+ {
+ "request": request,
+ "tpos": tpos,
+ "web_manifest": f"/tpos/manifest/{tpos_id}.webmanifest",
+ },
)
+
+
+@tpos_ext.get("/manifest/{tpos_id}.webmanifest")
+async def manifest(tpos_id: str):
+ tpos = await get_tpos(tpos_id)
+ if not tpos:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
+ )
+
+ return {
+ "short_name": LNBITS_SITE_TITLE,
+ "name": tpos.name + " - " + LNBITS_SITE_TITLE,
+ "icons": [
+ {
+ "src": LNBITS_CUSTOM_LOGO
+ if LNBITS_CUSTOM_LOGO
+ else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
+ "type": "image/png",
+ "sizes": "900x900",
+ }
+ ],
+ "start_url": "/tpos/" + tpos_id,
+ "background_color": "#1F2234",
+ "description": "Bitcoin Lightning tPOS",
+ "display": "standalone",
+ "scope": "/tpos/" + tpos_id,
+ "theme_color": "#1F2234",
+ "shortcuts": [
+ {
+ "name": tpos.name + " - " + LNBITS_SITE_TITLE,
+ "short_name": tpos.name,
+ "description": tpos.name + " - " + LNBITS_SITE_TITLE,
+ "url": "/tpos/" + tpos_id,
+ }
+ ],
+ }
diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py
index ae457b61..9609956e 100644
--- a/lnbits/extensions/tpos/views_api.py
+++ b/lnbits/extensions/tpos/views_api.py
@@ -2,6 +2,7 @@ from http import HTTPStatus
from fastapi import Query
from fastapi.params import Depends
+from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
@@ -16,7 +17,7 @@ from .models import CreateTposData
@tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK)
async def api_tposs(
- all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
+ all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
@@ -52,7 +53,9 @@ async def api_tpos_delete(
@tpos_ext.post("/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED)
-async def api_tpos_create_invoice(amount: int = Query(..., ge=1), tpos_id: str = None):
+async def api_tpos_create_invoice(
+ amount: int = Query(..., ge=1), tipAmount: int = None, tpos_id: str = None
+):
tpos = await get_tpos(tpos_id)
if not tpos:
@@ -60,12 +63,15 @@ async def api_tpos_create_invoice(amount: int = Query(..., ge=1), tpos_id: str =
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
+ if tipAmount:
+ amount += tipAmount
+
try:
payment_hash, payment_request = await create_invoice(
wallet_id=tpos.wallet,
amount=amount,
memo=f"{tpos.name}",
- extra={"tag": "tpos"},
+ extra={"tag": "tpos", "tipAmount": tipAmount, "tposId": tpos_id},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
@@ -84,7 +90,8 @@ async def api_tpos_check_invoice(tpos_id: str, payment_hash: str):
)
try:
status = await api_payment(payment_hash)
+
except Exception as exc:
- print(exc)
+ logger.error(exc)
return {"paid": False}
return status
diff --git a/lnbits/extensions/usermanager/models.py b/lnbits/extensions/usermanager/models.py
index 67facec6..15f50e28 100644
--- a/lnbits/extensions/usermanager/models.py
+++ b/lnbits/extensions/usermanager/models.py
@@ -1,8 +1,8 @@
from sqlite3 import Row
+from typing import Optional
from fastapi.param_functions import Query
from pydantic import BaseModel
-from typing import Optional
class CreateUserData(BaseModel):
diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html
index f3b1e8bd..886589e6 100644
--- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html
+++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html
@@ -14,7 +14,7 @@
extension allows the creation and management of users and wallets.
For example, a games developer may be developing a game that needs
each user to have their own wallet, LNbits can be included in the
- develpoers stack as the user and wallet manager.
+ developers stack as the user and wallet manager.
Created by, Ben Arc
@@ -28,6 +28,7 @@
label="API info"
:content-inset-level="0.5"
>
+
@@ -97,7 +98,7 @@
GET
- /usermanager/api/v1/wallets<wallet_id>
Headers
{"X-Api-Key": <string>}
@@ -109,7 +110,7 @@
Curl example
curl -X GET {{ request.base_url
- }}usermanager/api/v1/wallets<wallet_id> -H "X-Api-Key: {{
+ }}usermanager/api/v1/transactions/<wallet_id> -H "X-Api-Key: {{
user.wallets[0].inkey }}"
diff --git a/lnbits/extensions/usermanager/templates/usermanager/index.html b/lnbits/extensions/usermanager/templates/usermanager/index.html
index 6fbe9686..da11ad44 100644
--- a/lnbits/extensions/usermanager/templates/usermanager/index.html
+++ b/lnbits/extensions/usermanager/templates/usermanager/index.html
@@ -299,7 +299,7 @@
.request(
'GET',
'/usermanager/api/v1/users',
- this.g.user.wallets[0].inkey
+ this.g.user.wallets[0].adminkey
)
.then(function (response) {
self.users = response.data.map(function (obj) {
@@ -362,7 +362,7 @@
.request(
'DELETE',
'/usermanager/api/v1/users/' + userId,
- self.g.user.wallets[0].inkey
+ self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.users = _.reject(self.users, function (obj) {
@@ -389,7 +389,7 @@
.request(
'GET',
'/usermanager/api/v1/wallets',
- this.g.user.wallets[0].inkey
+ this.g.user.wallets[0].adminkey
)
.then(function (response) {
self.wallets = response.data.map(function (obj) {
@@ -447,7 +447,7 @@
.request(
'DELETE',
'/usermanager/api/v1/wallets/' + userId,
- self.g.user.wallets[0].inkey
+ self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.wallets = _.reject(self.wallets, function (obj) {
diff --git a/lnbits/extensions/usermanager/views_api.py b/lnbits/extensions/usermanager/views_api.py
index 8c652385..7e7b7653 100644
--- a/lnbits/extensions/usermanager/views_api.py
+++ b/lnbits/extensions/usermanager/views_api.py
@@ -6,7 +6,7 @@ from starlette.exceptions import HTTPException
from lnbits.core import update_user_extension
from lnbits.core.crud import get_user
-from lnbits.decorators import WalletTypeInfo, get_key_type
+from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import usermanager_ext
from .crud import (
@@ -27,7 +27,7 @@ from .models import CreateUserData, CreateUserWallet
@usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
-async def api_usermanager_users(wallet: WalletTypeInfo = Depends(get_key_type)):
+async def api_usermanager_users(wallet: WalletTypeInfo = Depends(require_admin_key)):
user_id = wallet.wallet.user
return [user.dict() for user in await get_usermanager_users(user_id)]
@@ -52,7 +52,7 @@ async def api_usermanager_users_create(
@usermanager_ext.delete("/api/v1/users/{user_id}")
async def api_usermanager_users_delete(
- user_id, wallet: WalletTypeInfo = Depends(get_key_type)
+ user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
):
user = await get_usermanager_user(user_id)
if not user:
@@ -75,7 +75,7 @@ async def api_usermanager_activate_extension(
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
)
- update_user_extension(user_id=userid, extension=extension, active=active)
+ await update_user_extension(user_id=userid, extension=extension, active=active)
return {"extension": "updated"}
@@ -93,7 +93,7 @@ async def api_usermanager_wallets_create(
@usermanager_ext.get("/api/v1/wallets")
-async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(get_key_type)):
+async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(require_admin_key)):
admin_id = wallet.wallet.user
return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)]
@@ -107,7 +107,7 @@ async def api_usermanager_wallet_transactions(
@usermanager_ext.get("/api/v1/wallets/{user_id}")
async def api_usermanager_users_wallets(
- user_id, wallet: WalletTypeInfo = Depends(get_key_type)
+ user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
):
return [
s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id)
@@ -116,7 +116,7 @@ async def api_usermanager_users_wallets(
@usermanager_ext.delete("/api/v1/wallets/{wallet_id}")
async def api_usermanager_wallets_delete(
- wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
+ wallet_id, wallet: WalletTypeInfo = Depends(require_admin_key)
):
get_wallet = await get_usermanager_wallet(wallet_id)
if not get_wallet:
diff --git a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html
index e8217192..94b44a44 100644
--- a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html
+++ b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html
@@ -20,6 +20,7 @@
label="API info"
:content-inset-level="0.5"
>
+
@@ -37,8 +38,8 @@
[<wallets_object>, ...]
Curl example
curl -X GET {{ request.base_url }}api/v1/wallet -H "X-Api-Key: {{
- user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}watchonly/api/v1/wallet -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -66,8 +67,9 @@
[<wallet_object>, ...]
Curl example
curl -X GET {{ request.base_url }}api/v1/wallet/<wallet_id>
- -H "X-Api-Key: {{ user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url
+ }}watchonly/api/v1/wallet/<wallet_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -89,9 +91,10 @@
[<wallet_object>, ...]
Curl example
curl -X POST {{ request.base_url }}api/v1/wallet -d '{"title":
- <string>, "masterpub": <string>}' -H "Content-type:
- application/json" -H "X-Api-Key: {{ user.wallets[0].adminkey }}"
+ >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 }}"
@@ -116,7 +119,7 @@
Curl example
curl -X DELETE {{ request.base_url
- }}api/v1/wallet/<wallet_id> -H "X-Api-Key: {{
+ }}watchonly/api/v1/wallet/<wallet_id> -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
@@ -142,7 +145,7 @@
Curl example
curl -X GET {{ request.base_url
- }}api/v1/addresses/<wallet_id> -H "X-Api-Key: {{
+ }}watchonly/api/v1/addresses/<wallet_id> -H "X-Api-Key: {{
user.wallets[0].inkey }}"
@@ -173,8 +176,9 @@
[<address_object>, ...]
Curl example
curl -X GET {{ request.base_url }}api/v1/address/<wallet_id>
- -H "X-Api-Key: {{ user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url
+ }}watchonly/api/v1/address/<wallet_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -202,8 +206,8 @@
[<mempool_object>, ...]
Curl example
curl -X GET {{ request.base_url }}api/v1/mempool -H "X-Api-Key: {{
- user.wallets[0].adminkey }}"
+ >curl -X GET {{ request.base_url }}watchonly/api/v1/mempool -H
+ "X-Api-Key: {{ user.wallets[0].adminkey }}"
@@ -233,9 +237,9 @@
[<mempool_object>, ...]
Curl example
curl -X PUT {{ request.base_url }}api/v1/mempool -d '{"endpoint":
- <string>}' -H "Content-type: application/json" -H "X-Api-Key:
- {{ user.wallets[0].adminkey }}"
+ >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/withdraw/README.md b/lnbits/extensions/withdraw/README.md
index 0e5939fd..7bf7c232 100644
--- a/lnbits/extensions/withdraw/README.md
+++ b/lnbits/extensions/withdraw/README.md
@@ -26,6 +26,8 @@ LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t
- on details you can print the vouchers\

- every printed LNURLw QR code is unique, it can only be used once
+3. Bonus: you can use an LNbits themed voucher, or use a custom one. There's a _template.svg_ file in `static/images` folder if you want to create your own.\
+ 
#### Advanced
diff --git a/lnbits/extensions/withdraw/__init__.py b/lnbits/extensions/withdraw/__init__.py
index 58ccfe7e..a0f4b606 100644
--- a/lnbits/extensions/withdraw/__init__.py
+++ b/lnbits/extensions/withdraw/__init__.py
@@ -9,7 +9,7 @@ db = Database("ext_withdraw")
withdraw_static_files = [
{
"path": "/withdraw/static",
- "app": StaticFiles(directory="lnbits/extensions/withdraw/static"),
+ "app": StaticFiles(packages=[("lnbits", "extensions/withdraw/static")]),
"name": "withdraw_static",
}
]
diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py
index 18a057f3..9868b057 100644
--- a/lnbits/extensions/withdraw/crud.py
+++ b/lnbits/extensions/withdraw/crud.py
@@ -25,9 +25,11 @@ async def create_withdraw_link(
unique_hash,
k1,
open_time,
- usescsv
+ usescsv,
+ webhook_url,
+ custom_url
)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
link_id,
@@ -42,6 +44,8 @@ async def create_withdraw_link(
urlsafe_short_hash(),
int(datetime.now().timestamp()) + data.wait_time,
usescsv,
+ data.webhook_url,
+ data.custom_url,
),
)
link = await get_withdraw_link(link_id, 0)
diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py
index e34add03..18a99599 100644
--- a/lnbits/extensions/withdraw/lnurl.py
+++ b/lnbits/extensions/withdraw/lnurl.py
@@ -1,10 +1,13 @@
import json
+import traceback
from datetime import datetime
from http import HTTPStatus
+import httpx
import shortuuid # type: ignore
from fastapi import HTTPException
from fastapi.param_functions import Query
+from loguru import logger
from starlette.requests import Request
from starlette.responses import HTMLResponse # type: ignore
@@ -30,7 +33,9 @@ async def api_lnurl_response(request: Request, unique_hash):
)
if link.is_spent:
- raise HTTPException(detail="Withdraw is spent.")
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
+ )
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
withdrawResponse = {
"tag": "withdrawRequest",
@@ -48,7 +53,11 @@ async def api_lnurl_response(request: Request, unique_hash):
@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback")
async def api_lnurl_callback(
- unique_hash, request: Request, k1: str = Query(...), pr: str = Query(...)
+ unique_hash,
+ request: Request,
+ k1: str = Query(...),
+ pr: str = Query(...),
+ id_unique_hash=None,
):
link = await get_withdraw_link_by_hash(unique_hash)
now = int(datetime.now().timestamp())
@@ -58,21 +67,38 @@ async def api_lnurl_callback(
)
if link.is_spent:
- raise HTTPException(status_code=HTTPStatus.OK, detail="Withdraw is spent.")
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
+ )
if link.k1 != k1:
- raise HTTPException(status_code=HTTPStatus.OK, detail="Bad request.")
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Bad request.")
if now < link.open_time:
return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}
+ usescsv = ""
try:
- usescsv = ""
for x in range(1, link.uses - link.used):
usecv = link.usescsv.split(",")
usescsv += "," + str(usecv[x])
usecsvback = usescsv
- usescsv = usescsv[1:]
+
+ found = False
+ if id_unique_hash is not None:
+ useslist = link.usescsv.split(",")
+ for ind, x in enumerate(useslist):
+ tohash = link.id + link.unique_hash + str(x)
+ if id_unique_hash == shortuuid.uuid(name=tohash):
+ found = True
+ useslist.pop(ind)
+ usescsv = ",".join(useslist)
+ if not found:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
+ )
+ else:
+ usescsv = usescsv[1:]
changesback = {
"open_time": link.wait_time,
@@ -89,16 +115,34 @@ async def api_lnurl_callback(
payment_request = pr
- await pay_invoice(
+ payment_hash = await pay_invoice(
wallet_id=link.wallet,
payment_request=payment_request,
max_sat=link.max_withdrawable,
extra={"tag": "withdraw"},
)
+
+ if link.webhook_url:
+ async with httpx.AsyncClient() as client:
+ try:
+ r = await client.post(
+ link.webhook_url,
+ json={
+ "payment_hash": payment_hash,
+ "payment_request": payment_request,
+ "lnurlw": link.id,
+ },
+ timeout=40,
+ )
+ except Exception as exc:
+ # webhook fails shouldn't cause the lnurlw to fail since invoice is already paid
+ logger.error("Caught exception when dispatching webhook url:", exc)
+
return {"status": "OK"}
except Exception as e:
await update_withdraw_link(link.id, **changesback)
+ logger.error(traceback.format_exc())
return {"status": "ERROR", "reason": "Link not working"}
@@ -115,11 +159,13 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash
if not link:
raise HTTPException(
- status_code=HTTPStatus.OK, detail="LNURL-withdraw not found."
+ status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
)
if link.is_spent:
- raise HTTPException(status_code=HTTPStatus.OK, detail="Withdraw is spent.")
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
+ )
useslist = link.usescsv.split(",")
found = False
@@ -127,15 +173,16 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash
tohash = link.id + link.unique_hash + str(x)
if id_unique_hash == shortuuid.uuid(name=tohash):
found = True
+
if not found:
raise HTTPException(
- status_code=HTTPStatus.OK, detail="LNURL-withdraw not found."
+ status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
)
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
withdrawResponse = {
"tag": "withdrawRequest",
- "callback": url,
+ "callback": url + "?id_unique_hash=" + id_unique_hash,
"k1": link.k1,
"minWithdrawable": link.min_withdrawable * 1000,
"maxWithdrawable": link.max_withdrawable * 1000,
diff --git a/lnbits/extensions/withdraw/migrations.py b/lnbits/extensions/withdraw/migrations.py
index 1a13aa6d..5484277a 100644
--- a/lnbits/extensions/withdraw/migrations.py
+++ b/lnbits/extensions/withdraw/migrations.py
@@ -108,3 +108,17 @@ async def m003_make_hash_check(db):
);
"""
)
+
+
+async def m004_webhook_url(db):
+ """
+ Adds webhook_url
+ """
+ await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_url TEXT;")
+
+
+async def m005_add_custom_print_design(db):
+ """
+ Adds custom print design
+ """
+ await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN custom_url TEXT;")
diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py
index a03c7db8..2672537f 100644
--- a/lnbits/extensions/withdraw/models.py
+++ b/lnbits/extensions/withdraw/models.py
@@ -15,6 +15,8 @@ class CreateWithdrawData(BaseModel):
uses: int = Query(..., ge=1)
wait_time: int = Query(..., ge=1)
is_unique: bool
+ webhook_url: str = Query(None)
+ custom_url: str = Query(None)
class WithdrawLink(BaseModel):
@@ -32,6 +34,8 @@ class WithdrawLink(BaseModel):
used: int = Query(0)
usescsv: str = Query(None)
number: int = Query(0)
+ webhook_url: str = Query(None)
+ custom_url: str = Query(None)
@property
def is_spent(self) -> bool:
diff --git a/lnbits/extensions/withdraw/static/js/index.js b/lnbits/extensions/withdraw/static/js/index.js
index 91ff6446..1982d684 100644
--- a/lnbits/extensions/withdraw/static/js/index.js
+++ b/lnbits/extensions/withdraw/static/js/index.js
@@ -20,9 +20,12 @@ var mapWithdrawLink = function (obj) {
obj.uses_left = obj.uses - obj.used
obj.print_url = [locationPath, 'print/', obj.id].join('')
obj.withdraw_url = [locationPath, obj.id].join('')
+ obj._data.use_custom = Boolean(obj.custom_url)
return obj
}
+const CUSTOM_URL = '/static/images/default_voucher.png'
+
new Vue({
el: '#vue',
mixins: [windowMixin],
@@ -53,18 +56,21 @@ new Vue({
rowsPerPage: 10
}
},
+ nfcTagWriting: false,
formDialog: {
show: false,
secondMultiplier: 'seconds',
secondMultiplierOptions: ['seconds', 'minutes', 'hours'],
data: {
- is_unique: false
+ is_unique: false,
+ use_custom: false
}
},
simpleformDialog: {
show: false,
data: {
is_unique: true,
+ use_custom: true,
title: 'Vouchers',
min_withdrawable: 0,
wait_time: 1
@@ -105,12 +111,14 @@ new Vue({
},
closeFormDialog: function () {
this.formDialog.data = {
- is_unique: false
+ is_unique: false,
+ use_custom: false
}
},
simplecloseFormDialog: function () {
this.simpleformDialog.data = {
- is_unique: false
+ is_unique: false,
+ use_custom: false
}
},
openQrCodeDialog: function (linkId) {
@@ -132,6 +140,9 @@ new Vue({
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
+ if (data.use_custom && !data?.custom_url) {
+ data.custom_url = CUSTOM_URL
+ }
data.wait_time =
data.wait_time *
@@ -140,7 +151,6 @@ new Vue({
minutes: 60,
hours: 3600
}[this.formDialog.secondMultiplier]
-
if (data.id) {
this.updateWithdrawLink(wallet, data)
} else {
@@ -158,6 +168,10 @@ new Vue({
data.title = 'vouchers'
data.is_unique = true
+ if (data.use_custom && !data?.custom_url) {
+ data.custom_url = '/static/images/default_voucher.png'
+ }
+
if (data.id) {
this.updateWithdrawLink(wallet, data)
} else {
@@ -179,7 +193,9 @@ new Vue({
'max_withdrawable',
'uses',
'wait_time',
- 'is_unique'
+ 'is_unique',
+ 'webhook_url',
+ 'custom_url'
)
)
.then(function (response) {
@@ -230,6 +246,42 @@ new Vue({
})
})
},
+ writeNfcTag: async function (lnurl) {
+ try {
+ if (typeof NDEFReader == 'undefined') {
+ throw {
+ toString: function () {
+ return 'NFC not supported on this device or browser.'
+ }
+ }
+ }
+
+ const ndef = new NDEFReader()
+
+ this.nfcTagWriting = true
+ this.$q.notify({
+ message: 'Tap your NFC tag to write the LNURL-withdraw link to it.'
+ })
+
+ await ndef.write({
+ records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}]
+ })
+
+ this.nfcTagWriting = false
+ this.$q.notify({
+ type: 'positive',
+ message: 'NFC tag written successfully.'
+ })
+ } catch (error) {
+ this.nfcTagWriting = false
+ this.$q.notify({
+ type: 'negative',
+ message: error
+ ? error.toString()
+ : 'An unexpected error has occurred.'
+ })
+ }
+ },
exportCSV: function () {
LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls)
}
diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
index c1172bcd..ff88189d 100644
--- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
+++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
@@ -4,6 +4,7 @@
label="API info"
:content-inset-level="0.5"
>
+
[<withdraw_link_object>, ...]
Curl example
curl -X GET {{ request.base_url }}withdraw/api/v1/links -H "X-Api-Key: {{
- user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}withdraw/api/v1/links -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -49,8 +50,9 @@
{"lnurl": <string>}
Curl example
curl -X GET {{ request.base_url }}withdraw/api/v1/links/<withdraw_id> -H
- "X-Api-Key: {{ user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url
+ }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -70,7 +72,8 @@
{"title": <string>, "min_withdrawable": <integer>,
"max_withdrawable": <integer>, "uses": <integer>,
- "wait_time": <integer>, "is_unique": <boolean>}
Returns 201 CREATED (application/json)
@@ -78,12 +81,12 @@
{"lnurl": <string>}
Curl example
curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title":
- <string>, "min_withdrawable": <integer>,
+ >curl -X POST {{ request.base_url }}withdraw/api/v1/links -d
+ '{"title": <string>, "min_withdrawable": <integer>,
"max_withdrawable": <integer>, "uses": <integer>,
- "wait_time": <integer>, "is_unique": <boolean>}' -H
- "Content-type: application/json" -H "X-Api-Key: {{
- user.wallets[0].adminkey }}"
+ "wait_time": <integer>, "is_unique": <boolean>,
+ "webhook_url": <string>}' -H "Content-type: application/json" -H
+ "X-Api-Key: {{ user.wallets[0].adminkey }}"
@@ -114,8 +117,9 @@
{"lnurl": <string>}
Curl example
curl -X PUT {{ request.base_url }}withdraw/api/v1/links/<withdraw_id> -d
- '{"title": <string>, "min_withdrawable": <integer>,
+ >curl -X PUT {{ request.base_url
+ }}withdraw/api/v1/links/<withdraw_id> -d '{"title":
+ <string>, "min_withdrawable": <integer>,
"max_withdrawable": <integer>, "uses": <integer>,
"wait_time": <integer>, "is_unique": <boolean>}' -H
"Content-type: application/json" -H "X-Api-Key: {{
@@ -142,8 +146,9 @@
Curl example
curl -X DELETE {{ request.base_url }}withdraw/api/v1/links/<withdraw_id>
- -H "X-Api-Key: {{ user.wallets[0].adminkey }}"
+ >curl -X DELETE {{ request.base_url
+ }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{
+ user.wallets[0].adminkey }}"
@@ -170,8 +175,8 @@
Curl example
curl -X GET {{ request.base_url
- }}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{
- user.wallets[0].inkey }}"
+ }}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
diff --git a/lnbits/extensions/withdraw/templates/withdraw/csv.html b/lnbits/extensions/withdraw/templates/withdraw/csv.html
index d8f8c4d0..62902905 100644
--- a/lnbits/extensions/withdraw/templates/withdraw/csv.html
+++ b/lnbits/extensions/withdraw/templates/withdraw/csv.html
@@ -1,10 +1,12 @@
-{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor %} {% endblock %} {% block scripts %}
+{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes
+in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor
+%} {% endblock %} {% block scripts %}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/lnbits/extensions/withdraw/templates/withdraw/display.html b/lnbits/extensions/withdraw/templates/withdraw/display.html
index 5552c77f..1e632741 100644
--- a/lnbits/extensions/withdraw/templates/withdraw/display.html
+++ b/lnbits/extensions/withdraw/templates/withdraw/display.html
@@ -13,14 +13,22 @@
:value="this.here + '/?lightning={{lnurl }}'"
:options="{width: 800}"
class="rounded-borders"
- >
+ >
+
-
+
Copy LNURL
+
@@ -51,7 +59,8 @@
mixins: [windowMixin],
data: function () {
return {
- here: location.protocol + '//' + location.host
+ here: location.protocol + '//' + location.host,
+ nfcTagWriting: false
}
}
})
diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html
index 13673028..9ff428a1 100644
--- a/lnbits/extensions/withdraw/templates/withdraw/index.html
+++ b/lnbits/extensions/withdraw/templates/withdraw/index.html
@@ -1,37 +1,50 @@
-{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block scripts %} {{ window_vars(user) }}
{% endblock %} {% block page %}
-
-
-
- Quick vouchers
- Advanced withdraw link(s)
-
-
+
+
+
+ Quick vouchers
+ Advanced withdraw link(s)
+
+
-
-
-
-
-
Withdraw links
-
-
- Export to CSV
-
-
-
- {% raw %}
-
+
+
+
+
+
Withdraw links
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
{{ col.label }}
+
-
+
{{ col.value }}
+
+
+ Webhook to {{ props.row.webhook_url}}
+
+
- {% endraw %}
-
-
-
-
+
+ {% endraw %}
+
+
+
+
-
-
-
-
- {{SITE_TITLE}} LNURL-withdraw extension
-
-
-
-
-
- {% include "withdraw/_api_docs.html" %}
-
- {% include "withdraw/_lnurl.html" %}
-
-
-
-
+
+
+
+
+ {{SITE_TITLE}} LNURL-withdraw extension
+
+
+
+
+
+ {% include "withdraw/_api_docs.html" %}
+
+ {% include "withdraw/_lnurl.html" %}
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Use unique withdraw QR codes to reduce `assmilking`
-
- This is recommended if you are sharing the links on social media or print QR codes.
-
-
-
-
-
Update withdraw link
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Use a custom voucher design
+ You can use an LNbits voucher design or a custom
+ one
+
+
+
+
+
+
+
+
+
+
+ Use unique withdraw QR codes to reduce `assmilking`
+
+ This is recommended if you are sharing the links on social
+ media or print QR codes.
+
+
+
+
+ Update withdraw link
+ Create withdraw link
- Cancel
-
-
-
-
+ formDialog.data.wait_time == null"
+ type="submit"
+ >Create withdraw link
+
Cancel
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Use a custom voucher design
+ You can use an LNbits voucher design or a custom
+ one
+
+
+
+
-
-
+ Create vouchers
- Cancel
-
-
-
-
+ simpleformDialog.data.uses == null"
+ type="submit"
+ >Create vouchers
+ Cancel
+
+
+
+
-
-
-
-
- {% raw %}
-
-
- ID: {{ qrCodeDialog.data.id }}
- Unique: {{ qrCodeDialog.data.is_unique }}
+
+
+
+
+ {% raw %}
+
+
+ ID: {{ qrCodeDialog.data.id }}
+ Unique: {{ qrCodeDialog.data.is_unique }}
(QR code will change after each withdrawal)
Max. withdrawable: {{
@@ -232,6 +421,13 @@
@click="copyText(qrCodeDialog.data.withdraw_url, 'Link copied to clipboard!')"
>Shareable link
+
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/lnbits/extensions/withdraw/templates/withdraw/print_qr_custom.html b/lnbits/extensions/withdraw/templates/withdraw/print_qr_custom.html
new file mode 100644
index 00000000..c95ba5a6
--- /dev/null
+++ b/lnbits/extensions/withdraw/templates/withdraw/print_qr_custom.html
@@ -0,0 +1,110 @@
+{% extends "print.html" %} {% block page %}
+
+
+
+ {% for page in link %}
+
+ {% for one in page %}
+
+
+
{{ amt }} sats
+
+
+
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+{% endblock %} {% block styles %}
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py
index 81aeef98..97fb1271 100644
--- a/lnbits/extensions/withdraw/views.py
+++ b/lnbits/extensions/withdraw/views.py
@@ -99,10 +99,23 @@ async def print_qr(request: Request, link_id):
page_link = list(chunks(links, 2))
linked = list(chunks(page_link, 5))
+ if link.custom_url:
+ return withdraw_renderer().TemplateResponse(
+ "withdraw/print_qr_custom.html",
+ {
+ "request": request,
+ "link": page_link,
+ "unique": True,
+ "custom_url": link.custom_url,
+ "amt": link.max_withdrawable,
+ },
+ )
+
return withdraw_renderer().TemplateResponse(
"withdraw/print_qr.html", {"request": request, "link": linked, "unique": True}
)
+
@withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse)
async def print_qr(request: Request, link_id):
link = await get_withdraw_link(link_id)
@@ -135,4 +148,4 @@ async def print_qr(request: Request, link_id):
return withdraw_renderer().TemplateResponse(
"withdraw/csv.html", {"request": request, "link": linked, "unique": True}
- )
\ No newline at end of file
+ )
diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py
index 8dd9e340..800fecce 100644
--- a/lnbits/extensions/withdraw/views_api.py
+++ b/lnbits/extensions/withdraw/views_api.py
@@ -60,7 +60,7 @@ async def api_link_retrieve(
raise HTTPException(
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
)
- return {**link, **{"lnurl": link.lnurl(request)}}
+ return {**link.dict(), **{"lnurl": link.lnurl(request)}}
@withdraw_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
@@ -71,6 +71,9 @@ async def api_link_create_or_update(
link_id: str = None,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
+ if data.uses > 250:
+ raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST)
+
if data.min_withdrawable < 1:
raise HTTPException(
detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST
diff --git a/lnbits/helpers.py b/lnbits/helpers.py
index cb6f8ee7..e97fc7bb 100644
--- a/lnbits/helpers.py
+++ b/lnbits/helpers.py
@@ -6,11 +6,10 @@ from typing import Any, List, NamedTuple, Optional
import jinja2
import shortuuid # type: ignore
+import lnbits.settings as settings
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.requestvars import g
-import lnbits.settings as settings
-
class Extension(NamedTuple):
code: str
@@ -26,14 +25,16 @@ class Extension(NamedTuple):
class ExtensionManager:
def __init__(self):
self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS
- self._admin_only: List[str] = [x.strip(' ') for x in settings.LNBITS_ADMIN_EXTENSIONS]
+ self._admin_only: List[str] = [
+ x.strip(" ") for x in settings.LNBITS_ADMIN_EXTENSIONS
+ ]
self._extension_folders: List[str] = [
x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions"))
][0]
@property
def extensions(self) -> List[Extension]:
- output = []
+ output: List[Extension] = []
if "all" in self._disabled:
return output
@@ -160,6 +161,7 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
["lnbits/templates", "lnbits/core/templates", *additional_folders]
)
)
+
if settings.LNBITS_AD_SPACE:
t.env.globals["AD_SPACE"] = settings.LNBITS_AD_SPACE
t.env.globals["HIDE_API"] = settings.LNBITS_HIDE_API
@@ -170,6 +172,8 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.LNBITS_THEME_OPTIONS
t.env.globals["LNBITS_VERSION"] = settings.LNBITS_COMMIT
t.env.globals["EXTENSIONS"] = get_valid_extensions()
+ if settings.LNBITS_CUSTOM_LOGO:
+ t.env.globals["USE_CUSTOM_LOGO"] = settings.LNBITS_CUSTOM_LOGO
if settings.DEBUG:
t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored())
diff --git a/lnbits/proxy_fix.py b/lnbits/proxy_fix.py
index ec2a85b1..897835e0 100644
--- a/lnbits/proxy_fix.py
+++ b/lnbits/proxy_fix.py
@@ -1,11 +1,11 @@
-from typing import Optional, List, Callable
from functools import partial
-from urllib.request import parse_http_list as _parse_list_header
+from typing import Callable, List, Optional
from urllib.parse import urlparse
-from werkzeug.datastructures import Headers
+from urllib.request import parse_http_list as _parse_list_header
from quart import Request
from quart_trio.asgi import TrioASGIHTTPConnection
+from werkzeug.datastructures import Headers
class ASGIProxyFix(TrioASGIHTTPConnection):
diff --git a/lnbits/server.py b/lnbits/server.py
new file mode 100644
index 00000000..4a63b3b7
--- /dev/null
+++ b/lnbits/server.py
@@ -0,0 +1,18 @@
+import click
+import uvicorn
+
+
+@click.command()
+@click.option("--port", default="5000", help="Port to run LNBits on")
+@click.option("--host", default="127.0.0.1", help="Host to run LNBits on")
+def main(port, host):
+ """Launched with `poetry run lnbits` at root level"""
+ uvicorn.run("lnbits.__main__:app", port=port, host=host)
+
+
+if __name__ == "__main__":
+ main()
+
+# def main():
+# """Launched with `poetry run start` at root level"""
+# uvicorn.run("lnbits.__main__:app")
diff --git a/lnbits/settings.py b/lnbits/settings.py
index 9ccd9e4e..5778b9e2 100644
--- a/lnbits/settings.py
+++ b/lnbits/settings.py
@@ -1,10 +1,9 @@
-import subprocess
import importlib
-
-from environs import Env # type: ignore
+import subprocess
from os import path
from typing import List
+from environs import Env # type: ignore
env = Env()
env.read_env()
@@ -14,8 +13,8 @@ wallet_class = getattr(
wallets_module, env.str("LNBITS_BACKEND_WALLET_CLASS", default="VoidWallet")
)
-ENV = env.str("QUART_ENV", default="production")
-DEBUG = env.bool("QUART_DEBUG", default=False) or ENV == "development"
+DEBUG = env.bool("DEBUG", default=False)
+
HOST = env.str("HOST", default="127.0.0.1")
PORT = env.int("PORT", default=5000)
@@ -29,7 +28,9 @@ LNBITS_ALLOWED_USERS: List[str] = env.list(
"LNBITS_ALLOWED_USERS", default=[], subcast=str
)
LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str)
-LNBITS_ADMIN_EXTENSIONS: List[str] = env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str)
+LNBITS_ADMIN_EXTENSIONS: List[str] = env.list(
+ "LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str
+)
LNBITS_DISABLED_EXTENSIONS: List[str] = env.list(
"LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str
)
@@ -47,8 +48,10 @@ LNBITS_THEME_OPTIONS: List[str] = env.list(
default="classic, flamingo, mint, salvador, monochrome, autumn",
subcast=str,
)
+LNBITS_CUSTOM_LOGO = env.str("LNBITS_CUSTOM_LOGO", default="")
WALLET = wallet_class()
+FAKE_WALLET = getattr(wallets_module, "FakeWallet")()
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")
PREFER_SECURE_URLS = env.bool("LNBITS_FORCE_HTTPS", default=True)
diff --git a/lnbits/static/images/default_voucher.png b/lnbits/static/images/default_voucher.png
new file mode 100644
index 00000000..8462b285
Binary files /dev/null and b/lnbits/static/images/default_voucher.png differ
diff --git a/lnbits/static/images/voucher_template.svg b/lnbits/static/images/voucher_template.svg
new file mode 100644
index 00000000..4347758f
--- /dev/null
+++ b/lnbits/static/images/voucher_template.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js
index c8863b5a..cd5fd1c4 100644
--- a/lnbits/static/js/base.js
+++ b/lnbits/static/js/base.js
@@ -315,6 +315,7 @@ window.windowMixin = {
data: function () {
return {
g: {
+ offline: !navigator.onLine,
visibleDrawer: false,
extensions: [],
user: null,
@@ -345,17 +346,26 @@ window.windowMixin = {
}
},
created: function () {
-
- if(this.$q.localStorage.getItem('lnbits.darkMode') == true || this.$q.localStorage.getItem('lnbits.darkMode') == false){
+ if (
+ this.$q.localStorage.getItem('lnbits.darkMode') == true ||
+ this.$q.localStorage.getItem('lnbits.darkMode') == false
+ ) {
this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode'))
- }
- else{
+ } else {
this.$q.dark.set(true)
}
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
+ addEventListener('offline', event => {
+ this.g.offline = true
+ })
+
+ addEventListener('online', event => {
+ this.g.offline = false
+ })
+
// failsafe if admin changes themes halfway
- if (!this.$q.localStorage.getItem('lnbits.theme')){
+ if (!this.$q.localStorage.getItem('lnbits.theme')) {
this.changeColor(this.g.allowedThemes[0])
}
if (
@@ -382,7 +392,7 @@ window.windowMixin = {
}
if (window.extensions) {
var user = this.g.user
- this.g.extensions = Object.freeze(
+ const extensions = Object.freeze(
window.extensions
.map(function (data) {
return window.LNbits.map.extension(data)
@@ -403,9 +413,13 @@ window.windowMixin = {
return obj
})
.sort(function (a, b) {
- return a.name > b.name
+ const nameA = a.name.toUpperCase()
+ const nameB = b.name.toUpperCase()
+ return nameA < nameB ? -1 : nameA > nameB ? 1 : 0
})
)
+
+ this.g.extensions = extensions
}
}
}
diff --git a/lnbits/static/scss/base.scss b/lnbits/static/scss/base.scss
index afafd50d..672a85b6 100644
--- a/lnbits/static/scss/base.scss
+++ b/lnbits/static/scss/base.scss
@@ -1,186 +1,149 @@
-$themes: (
- 'classic': (
- primary: #673ab7,
- secondary: #9c27b0,
- dark: #1f2234,
- info: #333646,
- marginal-bg: #1f2234,
- marginal-text: #fff
- ),
- 'bitcoin': (
- primary: #ff9853,
- secondary: #ff7353,
- dark: #2d293b,
- info: #333646,
- marginal-bg: #2d293b,
- marginal-text: #fff
- ),
- 'mint': (
- primary: #3ab77d,
- secondary: #27b065,
- dark: #1f342b,
- info: #334642,
- marginal-bg: #1f342b,
- marginal-text: #fff
- ),
- 'autumn': (
- primary: #b7763a,
- secondary: #b07927,
- dark: #34291f,
- info: #463f33,
- marginal-bg: #342a1f,
- marginal-text: rgb(255, 255, 255)
- ),
- 'flamingo': (
- primary: #d11d53,
- secondary: #db3e6d,
- dark: #803a45,
- info: #ec7599,
- marginal-bg: #803a45,
- marginal-text: rgb(255, 255, 255)
- ),
- 'monochrome': (
- primary: #494949,
- secondary: #6b6b6b,
- dark: #000,
- info: rgb(39, 39, 39),
- marginal-bg: #000,
- marginal-text: rgb(255, 255, 255)
- )
-);
-
-@each $theme, $colors in $themes {
- @each $name, $color in $colors {
- @if $name == 'dark' {
- [data-theme='#{$theme}'] .q-drawer--dark,
- body[data-theme='#{$theme}'].body--dark,
- [data-theme='#{$theme}'] .q-menu--dark {
- background: $color !important;
- }
-
- /* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
+$themes: ( 'classic': ( primary: #673ab7, secondary: #9c27b0, dark: #1f2234, info: #333646, marginal-bg: #1f2234, marginal-text: #fff), 'bitcoin': ( primary: #ff9853, secondary: #ff7353, dark: #2d293b, info: #333646, marginal-bg: #2d293b, marginal-text: #fff), 'freedom': ( primary: #e22156, secondary: #b91a45, dark: #0a0a0a, info: #1b1b1b, marginal-bg: #2d293b, marginal-text: #fff), 'mint': ( primary: #3ab77d, secondary: #27b065, dark: #1f342b, info: #334642, marginal-bg: #1f342b, marginal-text: #fff), 'autumn': ( primary: #b7763a, secondary: #b07927, dark: #34291f, info: #463f33, marginal-bg: #342a1f, marginal-text: rgb(255, 255, 255)), 'flamingo': ( primary: #d11d53, secondary: #db3e6d, dark: #803a45, info: #ec7599, marginal-bg: #803a45, marginal-text: rgb(255, 255, 255)), 'monochrome': ( primary: #494949, secondary: #6b6b6b, dark: #000, info: rgb(39, 39, 39), marginal-bg: #000, marginal-text: rgb(255, 255, 255)));
+@each $theme,
+$colors in $themes {
+ @each $name,
+ $color in $colors {
+ @if $name=='dark' {
+ [data-theme='#{$theme}'] .q-drawer--dark,
+ body[data-theme='#{$theme}'].body--dark,
+ [data-theme='#{$theme}'] .q-menu--dark {
+ background: $color !important;
+ }
+ /* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='#{$theme}'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
+ }
+ @if $name=='info' {
+ [data-theme='#{$theme}'] .q-card--dark,
+ [data-theme='#{$theme}'] .q-stepper--dark {
+ background: $color !important;
+ }
+ }
}
- @if $name == 'info' {
- [data-theme='#{$theme}'] .q-card--dark,
- [data-theme='#{$theme}'] .q-stepper--dark {
- background: $color !important;
- }
+ [data-theme='#{$theme}'] {
+ @each $name,
+ $color in $colors {
+ .bg-#{$name} {
+ background: $color !important;
+ }
+ .text-#{$name} {
+ color: $color !important;
+ }
+ }
}
- }
- [data-theme='#{$theme}'] {
- @each $name, $color in $colors {
- .bg-#{$name} {
- background: $color !important;
- }
- .text-#{$name} {
- color: $color !important;
- }
- }
- }
}
+
+[data-theme='freedom'] .q-drawer--dark {
+ background: #0a0a0a !important;
+}
+
+[data-theme='freedom'] .q-header {
+ background: #0a0a0a !important;
+}
+
[data-theme='salvador'] .q-drawer--dark {
- background: #242424 !important;
+ background: #242424 !important;
}
[data-theme='salvador'] .q-header {
- background: #0f47af !important;
+ background: #0f47af !important;
}
[data-theme='flamingo'] .q-drawer--dark {
- background: #e75480 !important;
+ background: #e75480 !important;
}
[data-theme='flamingo'] .q-header {
- background: #e75480 !important;
+ background: #e75480 !important;
}
[v-cloak] {
- display: none;
+ display: none;
}
body.body--dark .q-table--dark {
- background: transparent;
+ background: transparent;
}
body.body--dark .q-field--error {
- .text-negative,
- .q-field__messages {
- color: yellow !important;
- }
+ .text-negative,
+ .q-field__messages {
+ color: yellow !important;
+ }
}
.lnbits-drawer__q-list .q-item {
- padding-top: 5px !important;
- padding-bottom: 5px !important;
- border-top-right-radius: 3px;
- border-bottom-right-radius: 3px;
-
- &.q-item--active {
- color: inherit;
- font-weight: bold;
- }
+ padding-top: 5px !important;
+ padding-bottom: 5px !important;
+ border-top-right-radius: 3px;
+ border-bottom-right-radius: 3px;
+ &.q-item--active {
+ color: inherit;
+ font-weight: bold;
+ }
}
.lnbits__dialog-card {
- width: 500px;
+ width: 500px;
}
.q-table--dense {
- th:first-child,
- td:first-child,
- .q-table__bottom {
- padding-left: 6px !important;
- }
-
- th:last-child,
- td:last-child,
- .q-table__bottom {
- padding-right: 6px !important;
- }
+ th:first-child,
+ td:first-child,
+ .q-table__bottom {
+ padding-left: 6px !important;
+ }
+ th:last-child,
+ td:last-child,
+ .q-table__bottom {
+ padding-right: 6px !important;
+ }
}
a.inherit {
- color: inherit;
- text-decoration: none;
+ color: inherit;
+ text-decoration: none;
}
// QR video
-
video {
- border-radius: 3px;
+ border-radius: 3px;
}
// Material icons font
-
@font-face {
- font-family: 'Material Icons';
- font-style: normal;
- font-weight: 400;
- src: url(../fonts/material-icons-v50.woff2) format('woff2');
+ font-family: 'Material Icons';
+ font-style: normal;
+ font-weight: 400;
+ src: url(../fonts/material-icons-v50.woff2) format('woff2');
}
.material-icons {
- font-family: 'Material Icons';
- font-weight: normal;
- font-style: normal;
- font-size: 24px;
- line-height: 1;
- letter-spacing: normal;
- text-transform: none;
- display: inline-block;
- white-space: nowrap;
- word-wrap: normal;
- direction: ltr;
- -moz-font-feature-settings: 'liga';
- -moz-osx-font-smoothing: grayscale;
+ font-family: 'Material Icons';
+ font-weight: normal;
+ font-style: normal;
+ font-size: 24px;
+ line-height: 1;
+ letter-spacing: normal;
+ text-transform: none;
+ display: inline-block;
+ white-space: nowrap;
+ word-wrap: normal;
+ direction: ltr;
+ -moz-font-feature-settings: 'liga';
+ -moz-osx-font-smoothing: grayscale;
}
// text-wrap
.text-wrap {
- word-break: break-word;
+ word-break: break-word;
}
+
+.q-card {
+ code {
+ overflow-wrap: break-word;
+ }
+}
\ No newline at end of file
diff --git a/lnbits/tasks.py b/lnbits/tasks.py
index 4e73a0af..f4d0a928 100644
--- a/lnbits/tasks.py
+++ b/lnbits/tasks.py
@@ -1,20 +1,20 @@
-import time
import asyncio
+import time
import traceback
from http import HTTPStatus
-from typing import List, Callable
+from typing import Callable, List
from fastapi.exceptions import HTTPException
+from loguru import logger
-from lnbits.settings import WALLET
from lnbits.core.crud import (
- get_payments,
- get_standalone_payment,
delete_expired_invoices,
get_balance_checks,
+ get_payments,
+ get_standalone_payment,
)
from lnbits.core.services import redeem_lnurl_withdraw
-
+from lnbits.settings import WALLET
deferred_async: List[Callable] = []
@@ -37,9 +37,9 @@ async def catch_everything_and_restart(func):
except asyncio.CancelledError:
raise # because we must pass this up
except Exception as exc:
- print("caught exception in background task:", exc)
- print(traceback.format_exc())
- print("will restart the task in 5 seconds.")
+ logger.error("caught exception in background task:", exc)
+ logger.error(traceback.format_exc())
+ logger.error("will restart the task in 5 seconds.")
await asyncio.sleep(5)
await catch_everything_and_restart(func)
@@ -66,7 +66,7 @@ async def webhook_handler():
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
-internal_invoice_queue = asyncio.Queue(0)
+internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
async def internal_invoice_listener():
@@ -77,13 +77,11 @@ async def internal_invoice_listener():
async def invoice_listener():
async for checking_id in WALLET.paid_invoices_stream():
- print("> got a payment notification", checking_id)
+ logger.info("> got a payment notification", checking_id)
asyncio.create_task(invoice_callback_dispatcher(checking_id))
async def check_pending_payments():
- await delete_expired_invoices()
-
outgoing = True
incoming = True
@@ -98,6 +96,9 @@ async def check_pending_payments():
):
await payment.check_pending()
+ # we delete expired invoices once upon the first pending check
+ if incoming:
+ await delete_expired_invoices()
# after the first check we will only check outgoing, not incoming
# that will be handled by the global invoice listeners, hopefully
incoming = False
@@ -114,8 +115,9 @@ async def perform_balance_checks():
async def invoice_callback_dispatcher(checking_id: str):
- payment = await get_standalone_payment(checking_id)
+ payment = await get_standalone_payment(checking_id, incoming=True)
if payment and payment.is_in:
+ logger.trace("sending invoice callback for payment", checking_id)
await payment.set_pending(False)
for send_chan in invoice_listeners:
await send_chan.put(payment)
diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html
index 965d5336..acca92e7 100644
--- a/lnbits/templates/base.html
+++ b/lnbits/templates/base.html
@@ -7,7 +7,6 @@
{% endfor %}
-
{% block styles %}{% endblock %}
{% block title %}{{ SITE_TITLE }}{% endblock %}
@@ -17,7 +16,9 @@
/>
- {% block head_scripts %}{% endblock %}
+ {% if web_manifest %}
+
+ {% endif %} {% block head_scripts %}{% endblock %}
@@ -35,10 +36,13 @@
{% endblock %}
- {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{
- SITE_TITLE }} {% else %} LN bits {% endif %} {%
- endblock %}
+ {% block toolbar_title %} {% if USE_CUSTOM_LOGO %}
+
+ {%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else
+ %}
+ LN bits {% endif %} {%endif%} {%
+ endblock %}
+
{% block beta %}
@@ -49,6 +53,14 @@
>
{% endblock %}
+
+ OFFLINE
+
elSalvador
+ Freedom
+
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
const LNBITS_DENOMINATION = {{ LNBITS_DENOMINATION | tojson}}
- console.log(LNBITS_DENOMINATION)
if(themes && themes.length) {
window.allowedThemes = themes.map(str => str.trim())
}
diff --git a/lnbits/templates/error.html b/lnbits/templates/error.html
index 43a9ad8a..0205e003 100644
--- a/lnbits/templates/error.html
+++ b/lnbits/templates/error.html
@@ -13,7 +13,10 @@
>
{{ err }}
- If you believe this shouldn't be an error please bring it up on https://t.me/lnbits
+
+ If you believe this shouldn't be an error please bring it up on
+ https://t.me/lnbits
+
@@ -33,4 +36,4 @@
{% endblock %}
-
\ No newline at end of file
+
diff --git a/lnbits/templates/public.html b/lnbits/templates/public.html
index d2f0e65a..8e953ed1 100644
--- a/lnbits/templates/public.html
+++ b/lnbits/templates/public.html
@@ -1,7 +1,14 @@
{% extends "base.html" %} {% block beta %}{% endblock %} {% block drawer_toggle
%}{% endblock %} {% block drawer %}{% endblock %} {% block toolbar_title %}
-
- {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %}
- LN bits {% endif %}
+
+ {% if USE_CUSTOM_LOGO %}
+
+ {%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %}
+ LN bits
+ {% endif %} {% endif %}
{% endblock %}
diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py
index 4de1da8a..2801146b 100644
--- a/lnbits/utils/exchange_rates.py
+++ b/lnbits/utils/exchange_rates.py
@@ -1,7 +1,8 @@
import asyncio
-from typing import Callable, NamedTuple
+from typing import Callable, List, NamedTuple
import httpx
+from loguru import logger
currencies = {
"AED": "United Arab Emirates Dirham",
@@ -226,10 +227,10 @@ async def btc_price(currency: str) -> float:
"TO": currency.upper(),
"to": currency.lower(),
}
- rates = []
- tasks = []
+ rates: List[float] = []
+ tasks: List[asyncio.Task] = []
- send_channel = asyncio.Queue()
+ send_channel: asyncio.Queue = asyncio.Queue()
async def controller():
failures = 0
@@ -280,7 +281,7 @@ async def btc_price(currency: str) -> float:
if not rates:
return 9999999999
elif len(rates) == 1:
- print("Warning could only fetch one Bitcoin price.")
+ logger.warning("Could only fetch one Bitcoin price.")
return sum([rate for rate in rates]) / len(rates)
diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py
index 5674e701..8a2ca1a5 100644
--- a/lnbits/wallets/__init__.py
+++ b/lnbits/wallets/__init__.py
@@ -1,11 +1,12 @@
# flake8: noqa
-from .void import VoidWallet
from .clightning import CLightningWallet
-from .lntxbot import LntxbotWallet
-from .opennode import OpenNodeWallet
-from .lnpay import LNPayWallet
+from .eclair import EclairWallet
+from .fake import FakeWallet
from .lnbits import LNbitsWallet
from .lndrest import LndRestWallet
+from .lnpay import LNPayWallet
+from .lntxbot import LntxbotWallet
+from .opennode import OpenNodeWallet
from .spark import SparkWallet
-from .fake import FakeWallet
+from .void import VoidWallet
diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py
index 39c68759..f35eb370 100644
--- a/lnbits/wallets/base.py
+++ b/lnbits/wallets/base.py
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
-from typing import NamedTuple, Optional, AsyncGenerator, Coroutine
+from typing import AsyncGenerator, Coroutine, NamedTuple, Optional
class StatusResponse(NamedTuple):
diff --git a/lnbits/wallets/clightning.py b/lnbits/wallets/clightning.py
index f8c2b16c..fc79b3e3 100644
--- a/lnbits/wallets/clightning.py
+++ b/lnbits/wallets/clightning.py
@@ -5,10 +5,12 @@ except ImportError: # pragma: nocover
import asyncio
import random
+import time
from functools import partial, wraps
from os import getenv
from typing import AsyncGenerator, Optional
-import time
+
+from lnbits import bolt11 as lnbits_bolt11
from .base import (
InvoiceResponse,
@@ -18,7 +20,6 @@ from .base import (
Unsupported,
Wallet,
)
-from lnbits import bolt11 as lnbits_bolt11
def async_wrap(func):
diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py
new file mode 100644
index 00000000..ab99c699
--- /dev/null
+++ b/lnbits/wallets/eclair.py
@@ -0,0 +1,204 @@
+import asyncio
+import base64
+import json
+import urllib.parse
+from os import getenv
+from typing import AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
+
+# TODO: https://github.com/lnbits/lnbits-legend/issues/764
+# mypy https://github.com/aaugustin/websockets/issues/940
+from websockets import connect # type: ignore
+from websockets.exceptions import (
+ ConnectionClosed,
+ ConnectionClosedError,
+ ConnectionClosedOK,
+)
+
+from .base import (
+ InvoiceResponse,
+ PaymentResponse,
+ PaymentStatus,
+ StatusResponse,
+ Wallet,
+)
+
+
+class EclairError(Exception):
+ pass
+
+
+class UnknownError(Exception):
+ pass
+
+
+class EclairWallet(Wallet):
+ def __init__(self):
+ url = getenv("ECLAIR_URL")
+ self.url = url[:-1] if url.endswith("/") else url
+
+ self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws"
+
+ passw = getenv("ECLAIR_PASS")
+ encodedAuth = base64.b64encode(f":{passw}".encode("utf-8"))
+ auth = str(encodedAuth, "utf-8")
+ self.auth = {"Authorization": f"Basic {auth}"}
+
+ async def status(self) -> StatusResponse:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.url}/usablebalances", headers=self.auth, timeout=40
+ )
+ try:
+ data = r.json()
+ except:
+ return StatusResponse(
+ f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0
+ )
+
+ if r.is_error:
+ return StatusResponse(data["error"], 0)
+
+ return StatusResponse(None, data[0]["canSend"] * 1000)
+
+ async def create_invoice(
+ self,
+ amount: int,
+ memo: Optional[str] = None,
+ description_hash: Optional[bytes] = None,
+ ) -> InvoiceResponse:
+
+ data: Dict = {"amountMsat": amount * 1000}
+ if description_hash:
+ data["description_hash"] = description_hash.hex()
+ else:
+ data["description"] = memo or ""
+
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.url}/createinvoice", headers=self.auth, data=data, timeout=40
+ )
+
+ if r.is_error:
+ try:
+ data = r.json()
+ error_message = data["error"]
+ except:
+ error_message = r.text
+ pass
+
+ return InvoiceResponse(False, None, None, error_message)
+
+ data = r.json()
+ return InvoiceResponse(True, data["paymentHash"], data["serialized"], None)
+
+ async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.url}/payinvoice",
+ headers=self.auth,
+ data={"invoice": bolt11, "blocking": True},
+ timeout=None,
+ )
+
+ if "error" in r.json():
+ try:
+ data = r.json()
+ error_message = data["error"]
+ except:
+ error_message = r.text
+ pass
+ return PaymentResponse(False, None, 0, None, error_message)
+
+ data = r.json()
+
+ checking_id = data["paymentHash"]
+ preimage = data["paymentPreimage"]
+
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.url}/getsentinfo",
+ headers=self.auth,
+ data={"paymentHash": checking_id},
+ timeout=40,
+ )
+
+ if "error" in r.json():
+ try:
+ data = r.json()
+ error_message = data["error"]
+ except:
+ error_message = r.text
+ pass
+ return PaymentResponse(
+ True, checking_id, 0, preimage, error_message
+ ) ## ?? is this ok ??
+
+ data = r.json()
+ fees = [i["status"] for i in data]
+ fee_msat = sum([i["feesPaid"] for i in fees])
+
+ return PaymentResponse(True, checking_id, fee_msat, preimage, None)
+
+ async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ f"{self.url}/getreceivedinfo",
+ headers=self.auth,
+ data={"paymentHash": checking_id},
+ )
+ data = r.json()
+
+ if r.is_error or "error" in data:
+ return PaymentStatus(None)
+
+ if data["status"]["type"] != "received":
+ return PaymentStatus(False)
+
+ return PaymentStatus(True)
+
+ async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ url=f"{self.url}/getsentinfo",
+ headers=self.auth,
+ data={"paymentHash": checking_id},
+ )
+
+ data = r.json()[0]
+
+ if r.is_error:
+ return PaymentStatus(None)
+
+ if data["status"]["type"] != "sent":
+ return PaymentStatus(False)
+
+ return PaymentStatus(True)
+
+ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
+
+ try:
+ async with connect(
+ self.ws_url,
+ extra_headers=[("Authorization", self.auth["Authorization"])],
+ ) as ws:
+ while True:
+ message = await ws.recv()
+ message = json.loads(message)
+
+ if message and message["type"] == "payment-received":
+ yield message["paymentHash"]
+
+ except (
+ OSError,
+ ConnectionClosedOK,
+ ConnectionClosedError,
+ ConnectionClosed,
+ ) as ose:
+ logger.error("OSE", ose)
+ pass
+
+ logger.error("lost connection to eclair's websocket, retrying in 5 seconds")
+ await asyncio.sleep(5)
diff --git a/lnbits/wallets/fake.py b/lnbits/wallets/fake.py
index 331d5285..3126ee46 100644
--- a/lnbits/wallets/fake.py
+++ b/lnbits/wallets/fake.py
@@ -1,29 +1,34 @@
import asyncio
-import json
-import httpx
-from os import getenv
-from datetime import datetime, timedelta
-from typing import Optional, Dict, AsyncGenerator
-import random
-import string
-from lnbits.helpers import urlsafe_short_hash
import hashlib
-from ..bolt11 import encode, decode
+import random
+from datetime import datetime
+from os import getenv
+from typing import AsyncGenerator, Dict, Optional
+
+from environs import Env # type: ignore
+from loguru import logger
+
+from lnbits.helpers import urlsafe_short_hash
+
+from ..bolt11 import decode, encode
from .base import (
- StatusResponse,
InvoiceResponse,
PaymentResponse,
PaymentStatus,
+ StatusResponse,
Wallet,
)
+env = Env()
+env.read_env()
+
class FakeWallet(Wallet):
async def status(self) -> StatusResponse:
- print(
+ logger.info(
"FakeWallet funding source is for using LNbits as a centralised, stand-alone payment system with brrrrrr."
)
- return StatusResponse(None, float("inf"))
+ return StatusResponse(None, 1000000000)
async def create_invoice(
self,
@@ -31,7 +36,9 @@ class FakeWallet(Wallet):
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
- secret = getenv("FAKE_WALLET_SECRET")
+ # we set a default secret since FakeWallet is used for internal=True invoices
+ # and the user might not have configured a secret yet
+ secret = env.str("FAKE_WALLET_SECTRET", default="ToTheMoon1")
data: Dict = {
"out": False,
"amount": amount,
@@ -75,7 +82,7 @@ class FakeWallet(Wallet):
invoice = decode(bolt11)
if (
hasattr(invoice, "checking_id")
- and invoice.checking_id[6:] == data["privkey"][:6]
+ and invoice.checking_id[6:] == data["privkey"][:6] # type: ignore
):
return PaymentResponse(True, invoice.payment_hash, 0)
else:
@@ -84,13 +91,13 @@ class FakeWallet(Wallet):
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
- return PaymentStatus(False)
+ return PaymentStatus(None)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
- return PaymentStatus(False)
+ return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
- self.queue = asyncio.Queue(0)
+ self.queue: asyncio.Queue = asyncio.Queue(0)
while True:
value = await self.queue.get()
yield value
diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py
index 414e987b..d2ddb7ff 100644
--- a/lnbits/wallets/lnbits.py
+++ b/lnbits/wallets/lnbits.py
@@ -1,14 +1,16 @@
import asyncio
import json
-import httpx
from os import getenv
-from typing import Optional, Dict, AsyncGenerator
+from typing import AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
from .base import (
- StatusResponse,
InvoiceResponse,
PaymentResponse,
PaymentStatus,
+ StatusResponse,
Wallet,
)
@@ -86,7 +88,7 @@ class LNbitsWallet(Wallet):
url=f"{self.endpoint}/api/v1/payments",
headers=self.key,
json={"out": True, "bolt11": bolt11},
- timeout=100,
+ timeout=None,
)
ok, checking_id, fee_msat, error_message = not r.is_error, None, 0, None
@@ -144,5 +146,7 @@ class LNbitsWallet(Wallet):
except (OSError, httpx.ReadError, httpx.ConnectError, httpx.ReadTimeout):
pass
- print("lost connection to lnbits /payments/sse, retrying in 5 seconds")
+ logger.error(
+ "lost connection to lnbits /payments/sse, retrying in 5 seconds"
+ )
await asyncio.sleep(5)
diff --git a/lnbits/wallets/lnd_grpc_files/lightning_pb2.py b/lnbits/wallets/lnd_grpc_files/lightning_pb2.py
index 6a1cf8fe..9065e3f6 100644
--- a/lnbits/wallets/lnd_grpc_files/lightning_pb2.py
+++ b/lnbits/wallets/lnd_grpc_files/lightning_pb2.py
@@ -2,11 +2,11 @@
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: lightning.proto
"""Generated protocol buffer code."""
-from google.protobuf.internal import enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
+from google.protobuf.internal import enum_type_wrapper
# @@protoc_insertion_point(imports)
diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py
index f9a0496a..44fee78c 100644
--- a/lnbits/wallets/lndgrpc.py
+++ b/lnbits/wallets/lndgrpc.py
@@ -1,27 +1,30 @@
imports_ok = True
try:
- from google import protobuf
import grpc
+ from google import protobuf
except ImportError: # pragma: nocover
imports_ok = False
-import binascii
import base64
+import binascii
import hashlib
from os import environ, error, getenv
-from typing import Optional, Dict, AsyncGenerator
-from .macaroon import load_macaroon, AESCipher
+from typing import AsyncGenerator, Dict, Optional
+
+from loguru import logger
+
+from .macaroon import AESCipher, load_macaroon
if imports_ok:
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
from .base import (
- StatusResponse,
InvoiceResponse,
PaymentResponse,
PaymentStatus,
+ StatusResponse,
Wallet,
)
@@ -187,6 +190,8 @@ class LndWallet(Wallet):
checking_id = stringify_checking_id(i.r_hash)
yield checking_id
except error:
- print(error)
+ logger.error(error)
- print("lost connection to lnd InvoiceSubscription, please restart lnbits.")
+ logger.error(
+ "lost connection to lnd InvoiceSubscription, please restart lnbits."
+ )
diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py
index a107f749..575db64d 100644
--- a/lnbits/wallets/lndrest.py
+++ b/lnbits/wallets/lndrest.py
@@ -1,21 +1,23 @@
import asyncio
-from pydoc import describe
-import httpx
-import json
import base64
+import json
from os import getenv
-from typing import Optional, Dict, AsyncGenerator
+from pydoc import describe
+from typing import AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
from lnbits import bolt11 as lnbits_bolt11
-from .macaroon import load_macaroon, AESCipher
from .base import (
- StatusResponse,
InvoiceResponse,
PaymentResponse,
PaymentStatus,
+ StatusResponse,
Wallet,
)
+from .macaroon import AESCipher, load_macaroon
class LndRestWallet(Wallet):
@@ -109,7 +111,7 @@ class LndRestWallet(Wallet):
url=f"{self.endpoint}/v1/channels/transactions",
headers=self.auth,
json={"payment_request": bolt11, "fee_limit": lnrpcFeeLimit},
- timeout=180,
+ timeout=None,
)
if r.is_error or r.json().get("payment_error"):
@@ -191,5 +193,7 @@ class LndRestWallet(Wallet):
except (OSError, httpx.ConnectError, httpx.ReadError):
pass
- print("lost connection to lnd invoices stream, retrying in 5 seconds")
+ logger.error(
+ "lost connection to lnd invoices stream, retrying in 5 seconds"
+ )
await asyncio.sleep(5)
diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py
index 88e6a3a2..18b4f8bb 100644
--- a/lnbits/wallets/lnpay.py
+++ b/lnbits/wallets/lnpay.py
@@ -1,16 +1,18 @@
-import json
import asyncio
-from fastapi.exceptions import HTTPException
-import httpx
-from os import getenv
+import json
from http import HTTPStatus
-from typing import Optional, Dict, AsyncGenerator
+from os import getenv
+from typing import AsyncGenerator, Dict, Optional
+
+import httpx
+from fastapi.exceptions import HTTPException
+from loguru import logger
from .base import (
- StatusResponse,
InvoiceResponse,
PaymentResponse,
PaymentStatus,
+ StatusResponse,
Wallet,
)
@@ -82,7 +84,7 @@ class LNPayWallet(Wallet):
f"{self.endpoint}/wallet/{self.wallet_key}/withdraw",
headers=self.auth,
json={"payment_request": bolt11},
- timeout=180,
+ timeout=None,
)
try:
@@ -117,7 +119,7 @@ class LNPayWallet(Wallet):
return PaymentStatus(statuses[r.json()["settled"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
- self.queue = asyncio.Queue(0)
+ self.queue: asyncio.Queue = asyncio.Queue(0)
while True:
value = await self.queue.get()
yield value
@@ -127,7 +129,7 @@ class LNPayWallet(Wallet):
try:
data = json.loads(text)
except json.decoder.JSONDecodeError:
- print(f"got something wrong on lnpay webhook endpoint: {text[:200]}")
+ logger.error(f"got something wrong on lnpay webhook endpoint: {text[:200]}")
data = None
if (
type(data) is not dict
diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py
index bbd87a73..3c758e6c 100644
--- a/lnbits/wallets/lntxbot.py
+++ b/lnbits/wallets/lntxbot.py
@@ -1,14 +1,16 @@
import asyncio
import json
-import httpx
from os import getenv
-from typing import Optional, Dict, AsyncGenerator
+from typing import AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
from .base import (
- StatusResponse,
InvoiceResponse,
PaymentResponse,
PaymentStatus,
+ StatusResponse,
Wallet,
)
@@ -80,7 +82,7 @@ class LntxbotWallet(Wallet):
f"{self.endpoint}/payinvoice",
headers=self.auth,
json={"invoice": bolt11},
- timeout=100,
+ timeout=None,
)
if "error" in r.json():
@@ -143,5 +145,7 @@ class LntxbotWallet(Wallet):
except (OSError, httpx.ReadError, httpx.ReadTimeout, httpx.ConnectError):
pass
- print("lost connection to lntxbot /payments/stream, retrying in 5 seconds")
+ logger.error(
+ "lost connection to lntxbot /payments/stream, retrying in 5 seconds"
+ )
await asyncio.sleep(5)
diff --git a/lnbits/wallets/macaroon/__init__.py b/lnbits/wallets/macaroon/__init__.py
index b7cadcfe..16617aa6 100644
--- a/lnbits/wallets/macaroon/__init__.py
+++ b/lnbits/wallets/macaroon/__init__.py
@@ -1 +1 @@
-from .macaroon import load_macaroon, AESCipher
\ No newline at end of file
+from .macaroon import AESCipher, load_macaroon
diff --git a/lnbits/wallets/macaroon/macaroon.py b/lnbits/wallets/macaroon/macaroon.py
index dd6ff636..09155123 100644
--- a/lnbits/wallets/macaroon/macaroon.py
+++ b/lnbits/wallets/macaroon/macaroon.py
@@ -1,14 +1,17 @@
+import base64
+import getpass
+from hashlib import md5
+
from Cryptodome import Random
from Cryptodome.Cipher import AES
-import base64
-from hashlib import md5
-import getpass
+from loguru import logger
BLOCK_SIZE = 16
-import getpass
+import getpass
+
def load_macaroon(macaroon: str) -> str:
- """Returns hex version of a macaroon encoded in base64 or the file path.
+ """Returns hex version of a macaroon encoded in base64 or the file path.
:param macaroon: Macaroon encoded in base64 or file path.
:type macaroon: str
@@ -29,6 +32,7 @@ def load_macaroon(macaroon: str) -> str:
pass
return macaroon
+
class AESCipher(object):
"""This class is compatible with crypto-js/aes.js
@@ -39,6 +43,7 @@ class AESCipher(object):
AES.decrypt(encrypted, password).toString(Utf8);
"""
+
def __init__(self, key=None, description=""):
self.key = key
self.description = description + " "
@@ -47,7 +52,6 @@ class AESCipher(object):
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
return data + (chr(length) * length).encode()
-
def unpad(self, data):
return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))]
@@ -69,11 +73,10 @@ class AESCipher(object):
final_key += key
return final_key[:output]
- def decrypt(self, encrypted: str) -> str:
- """Decrypts a string using AES-256-CBC.
- """
+ def decrypt(self, encrypted: str) -> str: # type: ignore
+ """Decrypts a string using AES-256-CBC."""
passphrase = self.passphrase
- encrypted = base64.b64decode(encrypted)
+ encrypted = base64.b64decode(encrypted) # type: ignore
assert encrypted[0:8] == b"Salted__"
salt = encrypted[8:16]
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
@@ -81,7 +84,7 @@ class AESCipher(object):
iv = key_iv[32:]
aes = AES.new(key, AES.MODE_CBC, iv)
try:
- return self.unpad(aes.decrypt(encrypted[16:])).decode()
+ return self.unpad(aes.decrypt(encrypted[16:])).decode() # type: ignore
except UnicodeDecodeError:
raise ValueError("Wrong passphrase")
@@ -92,12 +95,15 @@ class AESCipher(object):
key = key_iv[:32]
iv = key_iv[32:]
aes = AES.new(key, AES.MODE_CBC, iv)
- return base64.b64encode(b"Salted__" + salt + aes.encrypt(self.pad(message))).decode()
+ return base64.b64encode(
+ b"Salted__" + salt + aes.encrypt(self.pad(message))
+ ).decode()
+
# if this file is executed directly, ask for a macaroon and encrypt it
if __name__ == "__main__":
macaroon = input("Enter macaroon: ")
macaroon = load_macaroon(macaroon)
macaroon = AESCipher(description="encryption").encrypt(macaroon.encode())
- print("Encrypted macaroon:")
- print(macaroon)
+ logger.info("Encrypted macaroon:")
+ logger.info(macaroon)
diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py
index 0ac205e2..be82365d 100644
--- a/lnbits/wallets/opennode.py
+++ b/lnbits/wallets/opennode.py
@@ -1,20 +1,22 @@
import asyncio
-
-from fastapi.exceptions import HTTPException
-from lnbits.helpers import url_for
import hmac
-import httpx
from http import HTTPStatus
from os import getenv
-from typing import Optional, AsyncGenerator
+from typing import AsyncGenerator, Optional
+
+import httpx
+from fastapi.exceptions import HTTPException
+from loguru import logger
+
+from lnbits.helpers import url_for
from .base import (
- StatusResponse,
InvoiceResponse,
PaymentResponse,
PaymentStatus,
- Wallet,
+ StatusResponse,
Unsupported,
+ Wallet,
)
@@ -83,7 +85,7 @@ class OpenNodeWallet(Wallet):
f"{self.endpoint}/v2/withdrawals",
headers=self.auth,
json={"type": "ln", "address": bolt11},
- timeout=180,
+ timeout=None,
)
if r.is_error:
@@ -125,7 +127,7 @@ class OpenNodeWallet(Wallet):
return PaymentStatus(statuses[r.json()["data"]["status"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
- self.queue = asyncio.Queue(0)
+ self.queue: asyncio.Queue = asyncio.Queue(0)
while True:
value = await self.queue.get()
yield value
@@ -139,7 +141,7 @@ class OpenNodeWallet(Wallet):
x = hmac.new(self.auth["Authorization"].encode("ascii"), digestmod="sha256")
x.update(charge_id.encode("ascii"))
if x.hexdigest() != data["hashed_order"]:
- print("invalid webhook, not from opennode")
+ logger.error("invalid webhook, not from opennode")
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
await self.queue.put(charge_id)
diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py
index 00bf6d3c..142e388d 100644
--- a/lnbits/wallets/spark.py
+++ b/lnbits/wallets/spark.py
@@ -1,15 +1,17 @@
import asyncio
import json
-import httpx
import random
from os import getenv
-from typing import Optional, AsyncGenerator
+from typing import AsyncGenerator, Optional
+
+import httpx
+from loguru import logger
from .base import (
- StatusResponse,
InvoiceResponse,
PaymentResponse,
PaymentStatus,
+ StatusResponse,
Wallet,
)
@@ -46,9 +48,16 @@ class SparkWallet(Wallet):
self.url + "/rpc",
headers={"X-Access": self.token},
json={"method": key, "params": params},
- timeout=40,
+ timeout=60 * 60 * 24,
)
- except (OSError, httpx.ConnectError, httpx.RequestError) as exc:
+ r.raise_for_status()
+ except (
+ OSError,
+ httpx.ConnectError,
+ httpx.RequestError,
+ httpx.HTTPError,
+ httpx.TimeoutException,
+ ) as exc:
raise UnknownError("error connecting to spark: " + str(exc))
try:
@@ -109,7 +118,10 @@ class SparkWallet(Wallet):
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
- r = await self.pay(bolt11)
+ r = await self.pay(
+ bolt11=bolt11,
+ maxfee=fee_limit_msat,
+ )
except (SparkError, UnknownError) as exc:
listpays = await self.listpays(bolt11)
if listpays:
@@ -129,7 +141,9 @@ class SparkWallet(Wallet):
if pay["status"] == "failed":
return PaymentResponse(False, None, 0, None, str(exc))
elif pay["status"] == "pending":
- return PaymentResponse(None, payment_hash, 0, None, None)
+ return PaymentResponse(
+ None, payment_hash, fee_limit_msat, None, None
+ )
elif pay["status"] == "complete":
r = pay
r["payment_preimage"] = pay["preimage"]
@@ -196,8 +210,14 @@ class SparkWallet(Wallet):
data = json.loads(line[5:])
if "pay_index" in data and data.get("status") == "paid":
yield data["label"]
- except (OSError, httpx.ReadError, httpx.ConnectError, httpx.ReadTimeout):
+ except (
+ OSError,
+ httpx.ReadError,
+ httpx.ConnectError,
+ httpx.ReadTimeout,
+ httpx.HTTPError,
+ ):
pass
- print("lost connection to spark /stream, retrying in 5 seconds")
+ logger.error("lost connection to spark /stream, retrying in 5 seconds")
await asyncio.sleep(5)
diff --git a/lnbits/wallets/void.py b/lnbits/wallets/void.py
index c5cc08b5..1139f7a8 100644
--- a/lnbits/wallets/void.py
+++ b/lnbits/wallets/void.py
@@ -1,5 +1,7 @@
from typing import AsyncGenerator, Optional
+from loguru import logger
+
from .base import (
InvoiceResponse,
PaymentResponse,
@@ -20,7 +22,7 @@ class VoidWallet(Wallet):
raise Unsupported("")
async def status(self) -> StatusResponse:
- print(
+ logger.info(
"This backend does nothing, it is here just as a placeholder, you must configure an actual backend before being able to do anything useful with LNbits."
)
return StatusResponse(None, 0)
diff --git a/mypy.ini b/mypy.ini
index f8b1844b..e5a974b5 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -1 +1,8 @@
[mypy]
+ignore_missing_imports = True
+exclude = (?x)(
+ ^lnbits/extensions.
+ | ^lnbits/wallets/lnd_grpc_files.
+ )
+[mypy-lnbits.wallets.lnd_grpc_files.*]
+follow_imports = skip
diff --git a/nix/modules/lnbits-service.nix b/nix/modules/lnbits-service.nix
new file mode 100644
index 00000000..5d8e0640
--- /dev/null
+++ b/nix/modules/lnbits-service.nix
@@ -0,0 +1,106 @@
+{ config, pkgs, lib, ... }:
+
+let
+ defaultUser = "lnbits";
+ cfg = config.services.lnbits;
+ inherit (lib) mkOption mkIf types optionalAttrs;
+in
+
+{
+ options = {
+ services.lnbits = {
+ enable = mkOption {
+ default = false;
+ type = types.bool;
+ description = ''
+ Whether to enable the lnbits service
+ '';
+ };
+ openFirewall = mkOption {
+ type = types.bool;
+ default = false;
+ description = ''
+ Whether to open the ports used by lnbits in the firewall for the server
+ '';
+ };
+ package = mkOption {
+ type = types.package;
+ default = pkgs.lnbits;
+ description = ''
+ The lnbits package to use.
+ '';
+ };
+ stateDir = mkOption {
+ type = types.path;
+ default = "/var/lib/lnbits";
+ description = ''
+ The lnbits state directory which LNBITS_DATA_FOLDER will be set to
+ '';
+ };
+ host = mkOption {
+ type = types.str;
+ default = "127.0.0.1";
+ description = ''
+ The host to bind to
+ '';
+ };
+ port = mkOption {
+ type = types.port;
+ default = 8231;
+ description = ''
+ The port to run on
+ '';
+ };
+ user = mkOption {
+ type = types.str;
+ default = "lnbits";
+ description = "user to run lnbits as";
+ };
+ group = mkOption {
+ type = types.str;
+ default = "lnbits";
+ description = "group to run lnbits as";
+ };
+ };
+ };
+
+ config = mkIf cfg.enable {
+ users.users = optionalAttrs (cfg.user == defaultUser) {
+ ${defaultUser} = {
+ isSystemUser = true;
+ group = defaultUser;
+ };
+ };
+
+ users.groups = optionalAttrs (cfg.group == defaultUser) {
+ ${defaultUser} = { };
+ };
+
+ systemd.tmpfiles.rules = [
+ "d ${cfg.stateDir} 0700 ${cfg.user} ${cfg.group} - -"
+ ];
+
+ systemd.services.lnbits = {
+ enable = true;
+ description = "lnbits";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network-online.target" ];
+ environment = {
+ LNBITS_DATA_FOLDER = "${cfg.stateDir}";
+ };
+ serviceConfig = {
+ User = cfg.user;
+ Group = cfg.group;
+ WorkingDirectory = "${cfg.package.src}";
+ StateDirectory = "${cfg.stateDir}";
+ ExecStart = "${lib.getExe cfg.package} --port ${toString cfg.port} --host ${cfg.host}";
+ Restart = "always";
+ PrivateTmp = true;
+ };
+ };
+ networking.firewall = mkIf cfg.openFirewall {
+ allowedTCPPorts = [ cfg.port ];
+ };
+ };
+}
+
diff --git a/nix/tests/default.nix b/nix/tests/default.nix
new file mode 100644
index 00000000..7b8513ac
--- /dev/null
+++ b/nix/tests/default.nix
@@ -0,0 +1,4 @@
+{ pkgs, makeTest, inputs }:
+{
+ vmTest = import ./nixos-module { inherit pkgs makeTest inputs; };
+}
diff --git a/nix/tests/nixos-module/default.nix b/nix/tests/nixos-module/default.nix
new file mode 100644
index 00000000..86857912
--- /dev/null
+++ b/nix/tests/nixos-module/default.nix
@@ -0,0 +1,25 @@
+{ pkgs, makeTest, inputs }:
+makeTest {
+ nodes = {
+ client = { config, pkgs, ... }: {
+ environment.systemPackages = [ pkgs.curl ];
+ };
+ lnbits = { ... }: {
+ imports = [ inputs.self.nixosModules.default ];
+ services.lnbits = {
+ enable = true;
+ openFirewall = true;
+ host = "0.0.0.0";
+ };
+ };
+ };
+ testScript = { nodes, ... }: ''
+ start_all()
+ lnbits.wait_for_open_port(${toString nodes.lnbits.config.services.lnbits.port})
+ client.wait_for_unit("multi-user.target")
+ with subtest("Check that the lnbits webserver can be reached."):
+ assert "
LNbits " in client.succeed(
+ "curl -sSf http:/lnbits:8231/ | grep title"
+ )
+ '';
+}
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 00000000..48c508ce
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1165 @@
+[[package]]
+name = "aiofiles"
+version = "0.7.0"
+description = "File support for asyncio."
+category = "main"
+optional = false
+python-versions = ">=3.6,<4.0"
+
+[[package]]
+name = "anyio"
+version = "3.6.1"
+description = "High level compatibility layer for multiple asynchronous event loop implementations"
+category = "main"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+idna = ">=2.8"
+sniffio = ">=1.1"
+
+[package.extras]
+doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
+test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
+trio = ["trio (>=0.16)"]
+
+[[package]]
+name = "asgiref"
+version = "3.4.1"
+description = "ASGI specs, helper code, and adapters"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
+
+[[package]]
+name = "attrs"
+version = "21.2.0"
+description = "Classes Without Boilerplate"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
+docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
+
+[[package]]
+name = "bech32"
+version = "1.2.0"
+description = "Reference implementation for Bech32 and segwit addresses."
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "bitstring"
+version = "3.1.9"
+description = "Simple construction, analysis and modification of binary data."
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "cerberus"
+version = "1.3.4"
+description = "Lightweight, extensible schema and data validation tool for Python dictionaries."
+category = "main"
+optional = false
+python-versions = ">=2.7"
+
+[[package]]
+name = "certifi"
+version = "2021.5.30"
+description = "Python package for providing Mozilla's CA Bundle."
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "cffi"
+version = "1.15.0"
+description = "Foreign Function Interface for Python calling C code."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pycparser = "*"
+
+[[package]]
+name = "charset-normalizer"
+version = "2.0.6"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+category = "main"
+optional = false
+python-versions = ">=3.5.0"
+
+[package.extras]
+unicode_backport = ["unicodedata2"]
+
+[[package]]
+name = "click"
+version = "8.0.1"
+description = "Composable command line interface toolkit"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.5"
+description = "Cross-platform colored terminal text."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "ecdsa"
+version = "0.17.0"
+description = "ECDSA cryptographic signature library (pure python)"
+category = "main"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[package.dependencies]
+six = ">=1.9.0"
+
+[package.extras]
+gmpy = ["gmpy"]
+gmpy2 = ["gmpy2"]
+
+[[package]]
+name = "embit"
+version = "0.4.9"
+description = "yet another bitcoin library"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "environs"
+version = "9.3.3"
+description = "simplified environment variable parsing"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+marshmallow = ">=3.0.0"
+python-dotenv = "*"
+
+[package.extras]
+dev = ["pytest", "dj-database-url", "dj-email-url", "django-cache-url", "flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "mypy (==0.910)", "pre-commit (>=2.4,<3.0)", "tox"]
+django = ["dj-database-url", "dj-email-url", "django-cache-url"]
+lint = ["flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "mypy (==0.910)", "pre-commit (>=2.4,<3.0)"]
+tests = ["pytest", "dj-database-url", "dj-email-url", "django-cache-url"]
+
+[[package]]
+name = "fastapi"
+version = "0.78.0"
+description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
+category = "main"
+optional = false
+python-versions = ">=3.6.1"
+
+[package.dependencies]
+pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0"
+starlette = "0.19.1"
+
+[package.extras]
+all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)"]
+dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)", "pre-commit (>=2.17.0,<3.0.0)"]
+doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer (>=0.4.1,<0.5.0)", "pyyaml (>=5.3.1,<7.0.0)"]
+test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==22.3.0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==4.2.1)", "types-orjson (==3.6.2)", "types-dataclasses (==0.6.5)"]
+
+[[package]]
+name = "h11"
+version = "0.12.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "httpcore"
+version = "0.13.7"
+description = "A minimal low-level HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+anyio = ">=3.0.0,<4.0.0"
+h11 = ">=0.11,<0.13"
+sniffio = ">=1.0.0,<2.0.0"
+
+[package.extras]
+http2 = ["h2 (>=3,<5)"]
+
+[[package]]
+name = "httptools"
+version = "0.2.0"
+description = "A collection of framework independent HTTP protocol utils."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+test = ["Cython (==0.29.22)"]
+
+[[package]]
+name = "httpx"
+version = "0.19.0"
+description = "The next generation HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+certifi = "*"
+charset-normalizer = "*"
+httpcore = ">=0.13.3,<0.14.0"
+rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotlicffi", "brotli"]
+http2 = ["h2 (>=3,<5)"]
+
+[[package]]
+name = "idna"
+version = "3.2"
+description = "Internationalized Domain Names in Applications (IDNA)"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "importlib-metadata"
+version = "4.8.1"
+description = "Read metadata from Python packages"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+perf = ["ipython"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+
+[[package]]
+name = "jinja2"
+version = "3.0.1"
+description = "A very fast and expressive template engine."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "lnurl"
+version = "0.3.6"
+description = "LNURL implementation for Python."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+bech32 = "*"
+pydantic = "*"
+
+[[package]]
+name = "loguru"
+version = "0.5.3"
+description = "Python logging made (stupidly) simple"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
+win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
+
+[package.extras]
+dev = ["codecov (>=2.0.15)", "colorama (>=0.3.4)", "flake8 (>=3.7.7)", "tox (>=3.9.0)", "tox-travis (>=0.12)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "Sphinx (>=2.2.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "black (>=19.10b0)", "isort (>=5.1.1)"]
+
+[[package]]
+name = "markupsafe"
+version = "2.0.1"
+description = "Safely add untrusted strings to HTML/XML markup."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "marshmallow"
+version = "3.13.0"
+description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+dev = ["pytest", "pytz", "simplejson", "mypy (==0.910)", "flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "pre-commit (>=2.4,<3.0)", "tox"]
+docs = ["sphinx (==4.1.1)", "sphinx-issues (==1.2.0)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.6)"]
+lint = ["mypy (==0.910)", "flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "pre-commit (>=2.4,<3.0)"]
+tests = ["pytest", "pytz", "simplejson"]
+
+[[package]]
+name = "outcome"
+version = "1.1.0"
+description = "Capture the outcome of Python function calls."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+attrs = ">=19.2.0"
+
+[[package]]
+name = "psycopg2-binary"
+version = "2.9.1"
+description = "psycopg2 - Python-PostgreSQL Database Adapter"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "pycparser"
+version = "2.21"
+description = "C parser in Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pycryptodomex"
+version = "3.14.1"
+description = "Cryptographic library for Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "pydantic"
+version = "1.8.2"
+description = "Data validation and settings management using python 3.6 type hinting"
+category = "main"
+optional = false
+python-versions = ">=3.6.1"
+
+[package.dependencies]
+typing-extensions = ">=3.7.4.3"
+
+[package.extras]
+dotenv = ["python-dotenv (>=0.10.4)"]
+email = ["email-validator (>=1.0.3)"]
+
+[[package]]
+name = "pypng"
+version = "0.0.21"
+description = "Pure Python library for saving and loading PNG images"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pyqrcode"
+version = "1.2.1"
+description = "A QR code generator written purely in Python with SVG, EPS, PNG and terminal output."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+PNG = ["pypng (>=0.0.13)"]
+
+[[package]]
+name = "pyscss"
+version = "1.3.7"
+description = "pyScss, a Scss compiler for Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+six = "*"
+
+[[package]]
+name = "python-dotenv"
+version = "0.19.0"
+description = "Read key-value pairs from a .env file and set them as environment variables"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+cli = ["click (>=5.0)"]
+
+[[package]]
+name = "pyyaml"
+version = "5.4.1"
+description = "YAML parser and emitter for Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+
+[[package]]
+name = "represent"
+version = "1.6.0.post0"
+description = "Create __repr__ automatically or declaratively."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+six = ">=1.8.0"
+
+[package.extras]
+test = ["ipython", "pytest (>=3.0.5)", "mock"]
+
+[[package]]
+name = "rfc3986"
+version = "1.5.0"
+description = "Validating URI References per RFC 3986"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
+
+[package.extras]
+idna2008 = ["idna"]
+
+[[package]]
+name = "secp256k1"
+version = "0.14.0"
+description = "FFI bindings to libsecp256k1"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+cffi = ">=1.3.0"
+
+[[package]]
+name = "shortuuid"
+version = "1.0.1"
+description = "A generator library for concise, unambiguous and URL-safe UUIDs."
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "sniffio"
+version = "1.2.0"
+description = "Sniff out which async library your code is running under"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "sqlalchemy"
+version = "1.3.23"
+description = "Database Abstraction Library"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+mssql = ["pyodbc"]
+mssql_pymssql = ["pymssql"]
+mssql_pyodbc = ["pyodbc"]
+mysql = ["mysqlclient"]
+oracle = ["cx-oracle"]
+postgresql = ["psycopg2"]
+postgresql_pg8000 = ["pg8000 (<1.16.6)"]
+postgresql_psycopg2binary = ["psycopg2-binary"]
+postgresql_psycopg2cffi = ["psycopg2cffi"]
+pymysql = ["pymysql (<1)", "pymysql"]
+
+[[package]]
+name = "sqlalchemy-aio"
+version = "0.16.0"
+description = "Async support for SQLAlchemy."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+outcome = "*"
+represent = ">=1.4"
+sqlalchemy = "*"
+
+[package.extras]
+test = ["pytest (>=5.4)", "pytest-asyncio (>=0.14)", "pytest-trio (>=0.6)"]
+test-noextras = ["pytest (>=5.4)", "pytest-asyncio (>=0.14)"]
+trio = ["trio (>=0.15)"]
+
+[[package]]
+name = "sse-starlette"
+version = "0.6.2"
+description = "SSE plugin for Starlette"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "starlette"
+version = "0.19.1"
+description = "The little ASGI library that shines."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+anyio = ">=3.4.0,<5"
+typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"]
+
+[[package]]
+name = "typing-extensions"
+version = "3.10.0.2"
+description = "Backported and Experimental Type Hints for Python 3.5+"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "uvicorn"
+version = "0.18.1"
+description = "The lightning-fast ASGI server."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+click = ">=7.0"
+h11 = ">=0.8"
+
+[package.extras]
+standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchfiles (>=0.13)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"]
+
+[[package]]
+name = "uvloop"
+version = "0.16.0"
+description = "Fast implementation of asyncio event loop on top of libuv"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"]
+docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"]
+test = ["aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"]
+
+[[package]]
+name = "watchgod"
+version = "0.7"
+description = "Simple, modern file watching and code reload in python."
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "websockets"
+version = "10.0"
+description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "win32-setctime"
+version = "1.1.0"
+description = "A small Python utility to set file creation time on Windows"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
+
+[[package]]
+name = "zipp"
+version = "3.5.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
+
+[metadata]
+lock-version = "1.1"
+python-versions = "^3.9"
+content-hash = "921a5f4fe1a4d1a4c3b490f8631ed4bdd0d8af1f1992f1a4f74eaed986c4eb0b"
+
+[metadata.files]
+aiofiles = [
+ {file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"},
+ {file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"},
+]
+anyio = [
+ {file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"},
+ {file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"},
+]
+asgiref = [
+ {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"},
+ {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"},
+]
+attrs = [
+ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
+ {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
+]
+bech32 = [
+ {file = "bech32-1.2.0-py3-none-any.whl", hash = "sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981"},
+ {file = "bech32-1.2.0.tar.gz", hash = "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899"},
+]
+bitstring = [
+ {file = "bitstring-3.1.9-py2-none-any.whl", hash = "sha256:e3e340e58900a948787a05e8c08772f1ccbe133f6f41fe3f0fa19a18a22bbf4f"},
+ {file = "bitstring-3.1.9-py3-none-any.whl", hash = "sha256:0de167daa6a00c9386255a7cac931b45e6e24e0ad7ea64f1f92a64ac23ad4578"},
+ {file = "bitstring-3.1.9.tar.gz", hash = "sha256:a5848a3f63111785224dca8bb4c0a75b62ecdef56a042c8d6be74b16f7e860e7"},
+]
+cerberus = [
+ {file = "Cerberus-1.3.4.tar.gz", hash = "sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c"},
+]
+certifi = [
+ {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
+ {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
+]
+cffi = [
+ {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"},
+ {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"},
+ {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"},
+ {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"},
+ {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"},
+ {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"},
+ {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"},
+ {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"},
+ {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"},
+ {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"},
+ {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"},
+ {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"},
+ {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"},
+ {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"},
+ {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"},
+ {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"},
+ {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"},
+ {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"},
+ {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"},
+ {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"},
+ {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"},
+ {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"},
+ {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"},
+ {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"},
+ {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"},
+ {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"},
+ {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"},
+ {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"},
+ {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"},
+ {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"},
+ {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"},
+ {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"},
+ {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"},
+ {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"},
+ {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"},
+ {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"},
+ {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"},
+ {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"},
+ {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"},
+ {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"},
+ {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"},
+ {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"},
+ {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"},
+ {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"},
+ {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"},
+ {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"},
+ {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"},
+ {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"},
+ {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"},
+ {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
+]
+charset-normalizer = [
+ {file = "charset-normalizer-2.0.6.tar.gz", hash = "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"},
+ {file = "charset_normalizer-2.0.6-py3-none-any.whl", hash = "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6"},
+]
+click = [
+ {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
+ {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
+]
+colorama = [
+ {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
+ {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
+]
+ecdsa = [
+ {file = "ecdsa-0.17.0-py2.py3-none-any.whl", hash = "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676"},
+ {file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"},
+]
+embit = [
+ {file = "embit-0.4.9.tar.gz", hash = "sha256:992332bd89af6e2d027e26fe437eb14aa33997db08c882c49064d49c3e6f4ab9"},
+]
+environs = [
+ {file = "environs-9.3.3-py2.py3-none-any.whl", hash = "sha256:ee5466156b50fe03aa9fec6e720feea577b5bf515d7f21b2c46608272557ba26"},
+ {file = "environs-9.3.3.tar.gz", hash = "sha256:72b867ff7b553076cdd90f3ee01ecc1cf854987639c9c459f0ed0d3d44ae490c"},
+]
+fastapi = [
+ {file = "fastapi-0.78.0-py3-none-any.whl", hash = "sha256:15fcabd5c78c266fa7ae7d8de9b384bfc2375ee0503463a6febbe3bab69d6f65"},
+ {file = "fastapi-0.78.0.tar.gz", hash = "sha256:3233d4a789ba018578658e2af1a4bb5e38bdd122ff722b313666a9b2c6786a83"},
+]
+h11 = [
+ {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
+ {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
+]
+httpcore = [
+ {file = "httpcore-0.13.7-py3-none-any.whl", hash = "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0"},
+ {file = "httpcore-0.13.7.tar.gz", hash = "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3"},
+]
+httptools = [
+ {file = "httptools-0.2.0-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:79dbc21f3612a78b28384e989b21872e2e3cf3968532601544696e4ed0007ce5"},
+ {file = "httptools-0.2.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:78d03dd39b09c99ec917d50189e6743adbfd18c15d5944392d2eabda688bf149"},
+ {file = "httptools-0.2.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a23166e5ae2775709cf4f7ad4c2048755ebfb272767d244e1a96d55ac775cca7"},
+ {file = "httptools-0.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3ab1f390d8867f74b3b5ee2a7ecc9b8d7f53750bd45714bf1cb72a953d7dfa77"},
+ {file = "httptools-0.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a7594f9a010cdf1e16a58b3bf26c9da39bbf663e3b8d46d39176999d71816658"},
+ {file = "httptools-0.2.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:01b392a166adcc8bc2f526a939a8aabf89fe079243e1543fd0e7dc1b58d737cb"},
+ {file = "httptools-0.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:80ffa04fe8c8dfacf6e4cef8277347d35b0442c581f5814f3b0cf41b65c43c6e"},
+ {file = "httptools-0.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d5682eeb10cca0606c4a8286a3391d4c3c5a36f0c448e71b8bd05be4e1694bfb"},
+ {file = "httptools-0.2.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:a289c27ccae399a70eacf32df9a44059ca2ba4ac444604b00a19a6c1f0809943"},
+ {file = "httptools-0.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:813871f961edea6cb2fe312f2d9b27d12a51ba92545380126f80d0de1917ea15"},
+ {file = "httptools-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:cc9be041e428c10f8b6ab358c6b393648f9457094e1dcc11b4906026d43cd380"},
+ {file = "httptools-0.2.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b08d00d889a118f68f37f3c43e359aab24ee29eb2e3fe96d64c6a2ba8b9d6557"},
+ {file = "httptools-0.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fd3b8905e21431ad306eeaf56644a68fdd621bf8f3097eff54d0f6bdf7262065"},
+ {file = "httptools-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:200fc1cdf733a9ff554c0bb97a4047785cfaad9875307d6087001db3eb2b417f"},
+ {file = "httptools-0.2.0.tar.gz", hash = "sha256:94505026be56652d7a530ab03d89474dc6021019d6b8682281977163b3471ea0"},
+]
+httpx = [
+ {file = "httpx-0.19.0-py3-none-any.whl", hash = "sha256:9bd728a6c5ec0a9e243932a9983d57d3cc4a87bb4f554e1360fce407f78f9435"},
+ {file = "httpx-0.19.0.tar.gz", hash = "sha256:92ecd2c00c688b529eda11cedb15161eaf02dee9116712f621c70d9a40b2cdd0"},
+]
+idna = [
+ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
+ {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
+]
+importlib-metadata = [
+ {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"},
+ {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"},
+]
+jinja2 = [
+ {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"},
+ {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"},
+]
+lnurl = [
+ {file = "lnurl-0.3.6-py3-none-any.whl", hash = "sha256:579982fd8c4d25bc84c61c74ec45cb7999fa1fa2426f5d5aeb0160ba333b9c92"},
+ {file = "lnurl-0.3.6.tar.gz", hash = "sha256:8af07460115a48f3122a5a9c9a6062bee3897d5f6ab4c9a60f6561a83a8234f6"},
+]
+loguru = [
+ {file = "loguru-0.5.3-py3-none-any.whl", hash = "sha256:f8087ac396b5ee5f67c963b495d615ebbceac2796379599820e324419d53667c"},
+ {file = "loguru-0.5.3.tar.gz", hash = "sha256:b28e72ac7a98be3d28ad28570299a393dfcd32e5e3f6a353dec94675767b6319"},
+]
+markupsafe = [
+ {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"},
+ {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"},
+]
+marshmallow = [
+ {file = "marshmallow-3.13.0-py2.py3-none-any.whl", hash = "sha256:dd4724335d3c2b870b641ffe4a2f8728a1380cd2e7e2312756715ffeaa82b842"},
+ {file = "marshmallow-3.13.0.tar.gz", hash = "sha256:c67929438fd73a2be92128caa0325b1b5ed8b626d91a094d2f7f2771bf1f1c0e"},
+]
+outcome = [
+ {file = "outcome-1.1.0-py2.py3-none-any.whl", hash = "sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958"},
+ {file = "outcome-1.1.0.tar.gz", hash = "sha256:e862f01d4e626e63e8f92c38d1f8d5546d3f9cce989263c521b2e7990d186967"},
+]
+psycopg2-binary = [
+ {file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"},
+ {file = "psycopg2_binary-2.9.1-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:24b0b6688b9f31a911f2361fe818492650795c9e5d3a1bc647acbd7440142a4f"},
+ {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:542875f62bc56e91c6eac05a0deadeae20e1730be4c6334d8f04c944fcd99759"},
+ {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661509f51531ec125e52357a489ea3806640d0ca37d9dada461ffc69ee1e7b6e"},
+ {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:d92272c7c16e105788efe2cfa5d680f07e34e0c29b03c1908f8636f55d5f915a"},
+ {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:736b8797b58febabb85494142c627bd182b50d2a7ec65322983e71065ad3034c"},
+ {file = "psycopg2_binary-2.9.1-cp310-cp310-win32.whl", hash = "sha256:ebccf1123e7ef66efc615a68295bf6fdba875a75d5bba10a05073202598085fc"},
+ {file = "psycopg2_binary-2.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:1f6ca4a9068f5c5c57e744b4baa79f40e83e3746875cac3c45467b16326bab45"},
+ {file = "psycopg2_binary-2.9.1-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76"},
+ {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aef9aee84ec78af51107181d02fe8773b100b01c5dfde351184ad9223eab3698"},
+ {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123c3fb684e9abfc47218d3784c7b4c47c8587951ea4dd5bc38b6636ac57f616"},
+ {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7"},
+ {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:fbb42a541b1093385a2d8c7eec94d26d30437d0e77c1d25dae1dcc46741a385e"},
+ {file = "psycopg2_binary-2.9.1-cp36-cp36m-win32.whl", hash = "sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d"},
+ {file = "psycopg2_binary-2.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce"},
+ {file = "psycopg2_binary-2.9.1-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a"},
+ {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a"},
+ {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a36c7eb6152ba5467fb264d73844877be8b0847874d4822b7cf2d3c0cb8cdcb0"},
+ {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed"},
+ {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f"},
+ {file = "psycopg2_binary-2.9.1-cp37-cp37m-win32.whl", hash = "sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e"},
+ {file = "psycopg2_binary-2.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1473c0215b0613dd938db54a653f68251a45a78b05f6fc21af4326f40e8360a2"},
+ {file = "psycopg2_binary-2.9.1-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140"},
+ {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917"},
+ {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14db1752acdd2187d99cb2ca0a1a6dfe57fc65c3281e0f20e597aac8d2a5bd90"},
+ {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:aed4a9a7e3221b3e252c39d0bf794c438dc5453bc2963e8befe9d4cd324dff72"},
+ {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34"},
+ {file = "psycopg2_binary-2.9.1-cp38-cp38-win32.whl", hash = "sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32"},
+ {file = "psycopg2_binary-2.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf"},
+ {file = "psycopg2_binary-2.9.1-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a"},
+ {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4"},
+ {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31"},
+ {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:0f2e04bd2a2ab54fa44ee67fe2d002bb90cee1c0f1cc0ebc3148af7b02034cbd"},
+ {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a"},
+ {file = "psycopg2_binary-2.9.1-cp39-cp39-win32.whl", hash = "sha256:0b7dae87f0b729922e06f85f667de7bf16455d411971b2043bbd9577af9d1975"},
+ {file = "psycopg2_binary-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68"},
+]
+pycparser = [
+ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
+ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
+]
+pycryptodomex = [
+ {file = "pycryptodomex-3.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ca88f2f7020002638276439a01ffbb0355634907d1aa5ca91f3dc0c2e44e8f3b"},
+ {file = "pycryptodomex-3.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:8536bc08d130cae6dcba1ea689f2913dfd332d06113904d171f2f56da6228e89"},
+ {file = "pycryptodomex-3.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:406ec8cfe0c098fadb18d597dc2ee6de4428d640c0ccafa453f3d9b2e58d29e2"},
+ {file = "pycryptodomex-3.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:da8db8374295fb532b4b0c467e66800ef17d100e4d5faa2bbbd6df35502da125"},
+ {file = "pycryptodomex-3.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:d709572d64825d8d59ea112e11cc7faf6007f294e9951324b7574af4251e4de8"},
+ {file = "pycryptodomex-3.14.1-cp27-cp27m-win32.whl", hash = "sha256:3da13c2535b7aea94cc2a6d1b1b37746814c74b6e80790daddd55ca5c120a489"},
+ {file = "pycryptodomex-3.14.1-cp27-cp27m-win_amd64.whl", hash = "sha256:298c00ea41a81a491d5b244d295d18369e5aac4b61b77b2de5b249ca61cd6659"},
+ {file = "pycryptodomex-3.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:77931df40bb5ce5e13f4de2bfc982b2ddc0198971fbd947776c8bb5050896eb2"},
+ {file = "pycryptodomex-3.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:c5dd3ffa663c982d7f1be9eb494a8924f6d40e2e2f7d1d27384cfab1b2ac0662"},
+ {file = "pycryptodomex-3.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2aa887683eee493e015545bd69d3d21ac8d5ad582674ec98f4af84511e353e45"},
+ {file = "pycryptodomex-3.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:8085bd0ad2034352eee4d4f3e2da985c2749cb7344b939f4d95ead38c2520859"},
+ {file = "pycryptodomex-3.14.1-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e95a4a6c54d27a84a4624d2af8bb9ee178111604653194ca6880c98dcad92f48"},
+ {file = "pycryptodomex-3.14.1-cp35-abi3-manylinux1_i686.whl", hash = "sha256:a4d412eba5679ede84b41dbe48b1bed8f33131ab9db06c238a235334733acc5e"},
+ {file = "pycryptodomex-3.14.1-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:d2cce1c82a7845d7e2e8a0956c6b7ed3f1661c9acf18eb120fc71e098ab5c6fe"},
+ {file = "pycryptodomex-3.14.1-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:f75009715dcf4a3d680c2338ab19dac5498f8121173a929872950f4fb3a48fbf"},
+ {file = "pycryptodomex-3.14.1-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:1ca8e1b4c62038bb2da55451385246f51f412c5f5eabd64812c01766a5989b4a"},
+ {file = "pycryptodomex-3.14.1-cp35-abi3-win32.whl", hash = "sha256:ee835def05622e0c8b1435a906491760a43d0c462f065ec9143ec4b8d79f8bff"},
+ {file = "pycryptodomex-3.14.1-cp35-abi3-win_amd64.whl", hash = "sha256:b5a185ae79f899b01ca49f365bdf15a45d78d9856f09b0de1a41b92afce1a07f"},
+ {file = "pycryptodomex-3.14.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:797a36bd1f69df9e2798e33edb4bd04e5a30478efc08f9428c087f17f65a7045"},
+ {file = "pycryptodomex-3.14.1-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:aebecde2adc4a6847094d3bd6a8a9538ef3438a5ea84ac1983fcb167db614461"},
+ {file = "pycryptodomex-3.14.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:f8524b8bc89470cec7ac51734907818d3620fb1637f8f8b542d650ebec42a126"},
+ {file = "pycryptodomex-3.14.1-pp27-pypy_73-win32.whl", hash = "sha256:4d0db8df9ffae36f416897ad184608d9d7a8c2b46c4612c6bc759b26c073f750"},
+ {file = "pycryptodomex-3.14.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b276cc4deb4a80f9dfd47a41ebb464b1fe91efd8b1b8620cf5ccf8b824b850d6"},
+ {file = "pycryptodomex-3.14.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:e36c7e3b5382cd5669cf199c4a04a0279a43b2a3bdd77627e9b89778ac9ec08c"},
+ {file = "pycryptodomex-3.14.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c4d8977ccda886d88dc3ca789de2f1adc714df912ff3934b3d0a3f3d777deafb"},
+ {file = "pycryptodomex-3.14.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:530756d2faa40af4c1f74123e1d889bd07feae45bac2fd32f259a35f7aa74151"},
+ {file = "pycryptodomex-3.14.1.tar.gz", hash = "sha256:2ce76ed0081fd6ac8c74edc75b9d14eca2064173af79843c24fa62573263c1f2"},
+]
+pydantic = [
+ {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"},
+ {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"},
+ {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"},
+ {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"},
+ {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"},
+ {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"},
+ {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"},
+ {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"},
+ {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"},
+ {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"},
+ {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"},
+ {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"},
+ {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"},
+ {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"},
+ {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"},
+ {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"},
+ {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"},
+ {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"},
+ {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"},
+ {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"},
+ {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"},
+ {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"},
+]
+pypng = [
+ {file = "pypng-0.0.21-py3-none-any.whl", hash = "sha256:76f8a1539ec56451da7ab7121f12a361969fe0f2d48d703d198ce2a99d6c5afd"},
+]
+pyqrcode = [
+ {file = "PyQRCode-1.2.1.tar.gz", hash = "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5"},
+ {file = "PyQRCode-1.2.1.zip", hash = "sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6"},
+]
+pyscss = [
+ {file = "pyScss-1.3.7.tar.gz", hash = "sha256:f1df571569021a23941a538eb154405dde80bed35dc1ea7c5f3e18e0144746bf"},
+]
+python-dotenv = [
+ {file = "python-dotenv-0.19.0.tar.gz", hash = "sha256:f521bc2ac9a8e03c736f62911605c5d83970021e3fa95b37d769e2bbbe9b6172"},
+ {file = "python_dotenv-0.19.0-py2.py3-none-any.whl", hash = "sha256:aae25dc1ebe97c420f50b81fb0e5c949659af713f31fdb63c749ca68748f34b1"},
+]
+pyyaml = [
+ {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
+ {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"},
+ {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"},
+ {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"},
+ {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"},
+ {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"},
+ {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"},
+ {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"},
+ {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"},
+ {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"},
+ {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"},
+ {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"},
+ {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"},
+ {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"},
+ {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"},
+ {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"},
+ {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"},
+ {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
+ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
+]
+represent = [
+ {file = "Represent-1.6.0.post0-py2.py3-none-any.whl", hash = "sha256:99142650756ef1998ce0661568f54a47dac8c638fb27e3816c02536575dbba8c"},
+ {file = "Represent-1.6.0.post0.tar.gz", hash = "sha256:026c0de2ee8385d1255b9c2426cd4f03fe9177ac94c09979bc601946c8493aa0"},
+]
+rfc3986 = [
+ {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
+ {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
+]
+secp256k1 = [
+ {file = "secp256k1-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f666c67dcf1dc69e1448b2ede5e12aaf382b600204a61dbc65e4f82cea444405"},
+ {file = "secp256k1-0.14.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fcabb3c3497a902fb61eec72d1b69bf72747d7bcc2a732d56d9319a1e8322262"},
+ {file = "secp256k1-0.14.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a27c479ab60571502516a1506a562d0a9df062de8ad645313fabfcc97252816"},
+ {file = "secp256k1-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4b9306bff6dde020444dfee9ca9b9f5b20ca53a2c0b04898361a3f43d5daf2e"},
+ {file = "secp256k1-0.14.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:72735da6cb28273e924431cd40aa607e7f80ef09608c8c9300be2e0e1d2417b4"},
+ {file = "secp256k1-0.14.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:87f4ad42a370f768910585989a301d1d65de17dcd86f6e8def9b021364b34d5c"},
+ {file = "secp256k1-0.14.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:130f119b06142e597c10eb4470b5a38eae865362d01aaef06b113478d77f728d"},
+ {file = "secp256k1-0.14.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:3aedcfe6eb1c5fa7c6be25b7cc91c76d8eb984271920ba0f7a934ae41ed56f51"},
+ {file = "secp256k1-0.14.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c91dd3154f6c46ac798d9a41166120e1751222587f54516cc3f378f56ce4ac82"},
+ {file = "secp256k1-0.14.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fec790cb6d0d37129ca0ce5b3f8e85692d5fb618d1c440f189453d18694035df"},
+ {file = "secp256k1-0.14.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:63eb148196b8f646922d4be6739b17fbbf50ebb3a020078c823e2445d88b7a81"},
+ {file = "secp256k1-0.14.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:adc23a4c5d24c95191638eb2ca313097827f07db102e77b59faed15d50c98cae"},
+ {file = "secp256k1-0.14.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ce0314788d3248b275426501228969fd32f6501c9d1837902ee0e7bd8264a36f"},
+ {file = "secp256k1-0.14.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bc761894b3634021686714278fc62b73395fa3eded33453eadfd8a00a6c44ef3"},
+ {file = "secp256k1-0.14.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:373dc8bca735f3c2d73259aa2711a9ecea2f3c7edbb663555fe3422e3dd76102"},
+ {file = "secp256k1-0.14.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fe3f503c9dfdf663b500d3e0688ad842e116c2907ad3f1e1d685812df3f56290"},
+ {file = "secp256k1-0.14.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4b1bf09953cde181132cf5e9033065615e5c2694e803165e2db763efa47695e5"},
+ {file = "secp256k1-0.14.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6af07be5f8612628c3638dc7b208f6cc78d0abae3e25797eadb13890c7d5da81"},
+ {file = "secp256k1-0.14.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a8dbd75a9fb6f42de307f3c5e24573fe59c3374637cbf39136edc66c200a4029"},
+ {file = "secp256k1-0.14.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:97a30c8dae633cb18135c76b6517ae99dc59106818e8985be70dbc05dcc06c0d"},
+ {file = "secp256k1-0.14.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f4062d8c101aa63b9ecb3709f1f075ad9c01b6672869bbaa1bd77271816936a7"},
+ {file = "secp256k1-0.14.0-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c9e7c024ff17e9b9d7c392bb2a917da231d6cb40ab119389ff1f51dca10339a4"},
+ {file = "secp256k1-0.14.0.tar.gz", hash = "sha256:82c06712d69ef945220c8b53c1a0d424c2ff6a1f64aee609030df79ad8383397"},
+]
+shortuuid = [
+ {file = "shortuuid-1.0.1-py3-none-any.whl", hash = "sha256:492c7402ff91beb1342a5898bd61ea953985bf24a41cd9f247409aa2e03c8f77"},
+ {file = "shortuuid-1.0.1.tar.gz", hash = "sha256:3c11d2007b915c43bee3e10625f068d8a349e04f0d81f08f5fa08507427ebf1f"},
+]
+six = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+sniffio = [
+ {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
+ {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
+]
+sqlalchemy = [
+ {file = "SQLAlchemy-1.3.23-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:fd3b96f8c705af8e938eaa99cbd8fd1450f632d38cad55e7367c33b263bf98ec"},
+ {file = "SQLAlchemy-1.3.23-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:29cccc9606750fe10c5d0e8bd847f17a97f3850b8682aef1f56f5d5e1a5a64b1"},
+ {file = "SQLAlchemy-1.3.23-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:927ce09e49bff3104459e1451ce82983b0a3062437a07d883a4c66f0b344c9b5"},
+ {file = "SQLAlchemy-1.3.23-cp27-cp27m-win32.whl", hash = "sha256:b4b0e44d586cd64b65b507fa116a3814a1a53d55dce4836d7c1a6eb2823ff8d1"},
+ {file = "SQLAlchemy-1.3.23-cp27-cp27m-win_amd64.whl", hash = "sha256:6b8b8c80c7f384f06825612dd078e4a31f0185e8f1f6b8c19e188ff246334205"},
+ {file = "SQLAlchemy-1.3.23-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:9e9c25522933e569e8b53ccc644dc993cab87e922fb7e142894653880fdd419d"},
+ {file = "SQLAlchemy-1.3.23-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:a0e306e9bb76fd93b29ae3a5155298e4c1b504c7cbc620c09c20858d32d16234"},
+ {file = "SQLAlchemy-1.3.23-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:6c9e6cc9237de5660bcddea63f332428bb83c8e2015c26777281f7ffbd2efb84"},
+ {file = "SQLAlchemy-1.3.23-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:94f667d86be82dd4cb17d08de0c3622e77ca865320e0b95eae6153faa7b4ecaf"},
+ {file = "SQLAlchemy-1.3.23-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:751934967f5336a3e26fc5993ccad1e4fee982029f9317eb6153bc0bc3d2d2da"},
+ {file = "SQLAlchemy-1.3.23-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:63677d0c08524af4c5893c18dbe42141de7178001360b3de0b86217502ed3601"},
+ {file = "SQLAlchemy-1.3.23-cp35-cp35m-win32.whl", hash = "sha256:ddfb511e76d016c3a160910642d57f4587dc542ce5ee823b0d415134790eeeb9"},
+ {file = "SQLAlchemy-1.3.23-cp35-cp35m-win_amd64.whl", hash = "sha256:040bdfc1d76a9074717a3f43455685f781c581f94472b010cd6c4754754e1862"},
+ {file = "SQLAlchemy-1.3.23-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:d1a85dfc5dee741bf49cb9b6b6b8d2725a268e4992507cf151cba26b17d97c37"},
+ {file = "SQLAlchemy-1.3.23-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:639940bbe1108ac667dcffc79925db2966826c270112e9159439ab6bb14f8d80"},
+ {file = "SQLAlchemy-1.3.23-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:e8a1750b44ad6422ace82bf3466638f1aa0862dbb9689690d5f2f48cce3476c8"},
+ {file = "SQLAlchemy-1.3.23-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e5bb3463df697279e5459a7316ad5a60b04b0107f9392e88674d0ece70e9cf70"},
+ {file = "SQLAlchemy-1.3.23-cp36-cp36m-win32.whl", hash = "sha256:e273367f4076bd7b9a8dc2e771978ef2bfd6b82526e80775a7db52bff8ca01dd"},
+ {file = "SQLAlchemy-1.3.23-cp36-cp36m-win_amd64.whl", hash = "sha256:ac2244e64485c3778f012951fdc869969a736cd61375fde6096d08850d8be729"},
+ {file = "SQLAlchemy-1.3.23-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:23927c3981d1ec6b4ea71eb99d28424b874d9c696a21e5fbd9fa322718be3708"},
+ {file = "SQLAlchemy-1.3.23-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d90010304abb4102123d10cbad2cdf2c25a9f2e66a50974199b24b468509bad5"},
+ {file = "SQLAlchemy-1.3.23-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:a8bfc1e1afe523e94974132d7230b82ca7fa2511aedde1f537ec54db0399541a"},
+ {file = "SQLAlchemy-1.3.23-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:269990b3ab53cb035d662dcde51df0943c1417bdab707dc4a7e4114a710504b4"},
+ {file = "SQLAlchemy-1.3.23-cp37-cp37m-win32.whl", hash = "sha256:fdd2ed7395df8ac2dbb10cefc44737b66c6a5cd7755c92524733d7a443e5b7e2"},
+ {file = "SQLAlchemy-1.3.23-cp37-cp37m-win_amd64.whl", hash = "sha256:6a939a868fdaa4b504e8b9d4a61f21aac11e3fecc8a8214455e144939e3d2aea"},
+ {file = "SQLAlchemy-1.3.23-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:24f9569e82a009a09ce2d263559acb3466eba2617203170e4a0af91e75b4f075"},
+ {file = "SQLAlchemy-1.3.23-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2578dbdbe4dbb0e5126fb37ffcd9793a25dcad769a95f171a2161030bea850ff"},
+ {file = "SQLAlchemy-1.3.23-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:1fe5d8d39118c2b018c215c37b73fd6893c3e1d4895be745ca8ff6eb83333ed3"},
+ {file = "SQLAlchemy-1.3.23-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:c7dc052432cd5d060d7437e217dd33c97025287f99a69a50e2dc1478dd610d64"},
+ {file = "SQLAlchemy-1.3.23-cp38-cp38-win32.whl", hash = "sha256:ecce8c021894a77d89808222b1ff9687ad84db54d18e4bd0500ca766737faaf6"},
+ {file = "SQLAlchemy-1.3.23-cp38-cp38-win_amd64.whl", hash = "sha256:37b83bf81b4b85dda273aaaed5f35ea20ad80606f672d94d2218afc565fb0173"},
+ {file = "SQLAlchemy-1.3.23-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:8be835aac18ec85351385e17b8665bd4d63083a7160a017bef3d640e8e65cadb"},
+ {file = "SQLAlchemy-1.3.23-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6ec1044908414013ebfe363450c22f14698803ce97fbb47e53284d55c5165848"},
+ {file = "SQLAlchemy-1.3.23-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:eab063a70cca4a587c28824e18be41d8ecc4457f8f15b2933584c6c6cccd30f0"},
+ {file = "SQLAlchemy-1.3.23-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:baeb451ee23e264de3f577fee5283c73d9bbaa8cb921d0305c0bbf700094b65b"},
+ {file = "SQLAlchemy-1.3.23-cp39-cp39-win32.whl", hash = "sha256:94208867f34e60f54a33a37f1c117251be91a47e3bfdb9ab8a7847f20886ad06"},
+ {file = "SQLAlchemy-1.3.23-cp39-cp39-win_amd64.whl", hash = "sha256:f4d972139d5000105fcda9539a76452039434013570d6059993120dc2a65e447"},
+ {file = "SQLAlchemy-1.3.23.tar.gz", hash = "sha256:6fca33672578666f657c131552c4ef8979c1606e494f78cd5199742dfb26918b"},
+]
+sqlalchemy-aio = [
+ {file = "sqlalchemy_aio-0.16.0-py2.py3-none-any.whl", hash = "sha256:f767320427c22c66fa5840a1f17f3261110a8ddc8560558f4fbf12d31a66b17b"},
+ {file = "sqlalchemy_aio-0.16.0.tar.gz", hash = "sha256:7f77366f55d34891c87386dd0962a28b948b684e8ea5edb7daae4187c0b291bf"},
+]
+sse-starlette = [
+ {file = "sse-starlette-0.6.2.tar.gz", hash = "sha256:1c0cc62cc7d021a386dc06a16a9ddc3e2861d19da6bc2e654e65cc111e820456"},
+]
+starlette = [
+ {file = "starlette-0.19.1-py3-none-any.whl", hash = "sha256:5a60c5c2d051f3a8eb546136aa0c9399773a689595e099e0877704d5888279bf"},
+ {file = "starlette-0.19.1.tar.gz", hash = "sha256:c6d21096774ecb9639acad41b86b7706e52ba3bf1dc13ea4ed9ad593d47e24c7"},
+]
+typing-extensions = [
+ {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
+ {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
+ {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
+]
+uvicorn = [
+ {file = "uvicorn-0.18.1-py3-none-any.whl", hash = "sha256:013c4ea0787cc2dc456ef4368e18c01982e6be57903e4d3183218e543eb889b7"},
+ {file = "uvicorn-0.18.1.tar.gz", hash = "sha256:35703e6518105cfe53f16a5a9435db3e2e227d0784f1fd8fbc1214b1fdc108df"},
+]
+uvloop = [
+ {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d"},
+ {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c"},
+ {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64"},
+ {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9"},
+ {file = "uvloop-0.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638"},
+ {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450"},
+ {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805"},
+ {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382"},
+ {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee"},
+ {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464"},
+ {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab"},
+ {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f"},
+ {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897"},
+ {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f"},
+ {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861"},
+ {file = "uvloop-0.16.0.tar.gz", hash = "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228"},
+]
+watchgod = [
+ {file = "watchgod-0.7-py3-none-any.whl", hash = "sha256:d6c1ea21df37847ac0537ca0d6c2f4cdf513562e95f77bb93abbcf05573407b7"},
+ {file = "watchgod-0.7.tar.gz", hash = "sha256:48140d62b0ebe9dd9cf8381337f06351e1f2e70b2203fa9c6eff4e572ca84f29"},
+]
+websockets = [
+ {file = "websockets-10.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cd8c6f2ec24aedace251017bc7a414525171d4e6578f914acab9349362def4da"},
+ {file = "websockets-10.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1f6b814cff6aadc4288297cb3a248614829c6e4ff5556593c44a115e9dd49939"},
+ {file = "websockets-10.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:01db0ecd1a0ca6702d02a5ed40413e18b7d22f94afb3bbe0d323bac86c42c1c8"},
+ {file = "websockets-10.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:82b17524b1ce6ae7f7dd93e4d18e9b9474071e28b65dbf1dfe9b5767778db379"},
+ {file = "websockets-10.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8bbf8660c3f833ddc8b1afab90213f2e672a9ddac6eecb3cde968e6b2807c1c7"},
+ {file = "websockets-10.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b8176deb6be540a46695960a765a77c28ac8b2e3ef2ec95d50a4f5df901edb1c"},
+ {file = "websockets-10.0-cp37-cp37m-win32.whl", hash = "sha256:706e200fc7f03bed99ad0574cd1ea8b0951477dd18cc978ccb190683c69dba76"},
+ {file = "websockets-10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5b2600e01c7ca6f840c42c747ffbe0254f319594ed108db847eb3d75f4aacb80"},
+ {file = "websockets-10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:085bb8a6e780d30eaa1ba48ac7f3a6707f925edea787cfb761ce5a39e77ac09b"},
+ {file = "websockets-10.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9a4d889162bd48588e80950e07fa5e039eee9deb76a58092e8c3ece96d7ef537"},
+ {file = "websockets-10.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b4ade7569b6fd17912452f9c3757d96f8e4044016b6d22b3b8391e641ca50456"},
+ {file = "websockets-10.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:2a43072e434c041a99f2e1eb9b692df0232a38c37c61d00e9f24db79474329e4"},
+ {file = "websockets-10.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:7f79f02c7f9a8320aff7d3321cd1c7e3a7dbc15d922ac996cca827301ee75238"},
+ {file = "websockets-10.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:1ac35426fe3e7d3d0fac3d63c8965c76ed67a8fd713937be072bf0ce22808539"},
+ {file = "websockets-10.0-cp38-cp38-win32.whl", hash = "sha256:ff59c6bdb87b31f7e2d596f09353d5a38c8c8ff571b0e2238e8ee2d55ad68465"},
+ {file = "websockets-10.0-cp38-cp38-win_amd64.whl", hash = "sha256:d67646ddd17a86117ae21c27005d83c1895c0cef5d7be548b7549646372f868a"},
+ {file = "websockets-10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:82bd921885231f4a30d9bc550552495b3fc36b1235add6d374e7c65c3babd805"},
+ {file = "websockets-10.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7d2e12e4f901f1bc062dfdf91831712c4106ed18a9a4cdb65e2e5f502124ca37"},
+ {file = "websockets-10.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:71358c7816e2762f3e4af3adf0040f268e219f5a38cb3487a9d0fc2e554fef6a"},
+ {file = "websockets-10.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:fe83b3ec9ef34063d86dfe1029160a85f24a5a94271036e5714a57acfdd089a1"},
+ {file = "websockets-10.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:eb282127e9c136f860c6068a4fba5756eb25e755baffb5940b6f1eae071928b2"},
+ {file = "websockets-10.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:62160772314920397f9d219147f958b33fa27a12c662d4455c9ccbba9a07e474"},
+ {file = "websockets-10.0-cp39-cp39-win32.whl", hash = "sha256:e42a1f1e03437b017af341e9bbfdc09252cd48ef32a8c3c3ead769eab3b17368"},
+ {file = "websockets-10.0-cp39-cp39-win_amd64.whl", hash = "sha256:c5880442f5fc268f1ef6d37b2c152c114deccca73f48e3a8c48004d2f16f4567"},
+ {file = "websockets-10.0.tar.gz", hash = "sha256:c4fc9a1d242317892590abe5b61a9127f1a61740477bfb121743f290b8054002"},
+]
+win32-setctime = []
+zipp = [
+ {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"},
+ {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"},
+]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..5c4bc7a0
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,70 @@
+[tool.poetry]
+name = "lnbits"
+version = "0.1.0"
+description = ""
+authors = ["matthewcroughan
"]
+
+[tool.poetry.build]
+generate-setup-file = false
+script = "build.py"
+
+[tool.poetry.dependencies]
+python = "^3.9"
+aiofiles = "0.7.0"
+asgiref = "3.4.1"
+attrs = "21.2.0"
+bech32 = "1.2.0"
+bitstring = "3.1.9"
+cerberus = "1.3.4"
+certifi = "2021.5.30"
+charset-normalizer = "2.0.6"
+click = "8.0.1"
+ecdsa = "0.17.0"
+embit = "0.4.9"
+environs = "9.3.3"
+fastapi = "0.78.0"
+h11 = "0.12.0"
+httpcore = "0.13.7"
+httptools = "0.2.0"
+httpx = "0.19.0"
+idna = "3.2"
+importlib-metadata = "4.8.1"
+jinja2 = "3.0.1"
+lnurl = "0.3.6"
+markupsafe = "2.0.1"
+marshmallow = "3.13.0"
+outcome = "1.1.0"
+psycopg2-binary = "2.9.1"
+pycryptodomex = "3.14.1"
+pydantic = "1.8.2"
+pypng = "0.0.21"
+pyqrcode = "1.2.1"
+pyscss = "1.3.7"
+python-dotenv = "0.19.0"
+pyyaml = "5.4.1"
+represent = "1.6.0.post0"
+rfc3986 = "1.5.0"
+secp256k1 = "0.14.0"
+shortuuid = "1.0.1"
+six = "1.16.0"
+sniffio = "1.2.0"
+sqlalchemy = "1.3.23"
+sqlalchemy-aio = "0.16.0"
+sse-starlette = "0.6.2"
+typing-extensions = "3.10.0.2"
+uvicorn = "0.18.1"
+uvloop = "0.16.0"
+watchgod = "0.7"
+websockets = "10.0"
+zipp = "3.5.0"
+loguru = "0.5.3"
+cffi = "1.15.0"
+
+[tool.poetry.dev-dependencies]
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.poetry.scripts]
+lnbits = "lnbits.server:main"
diff --git a/requirements.txt b/requirements.txt
index b84e4ab6..23d428e5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,50 +1,52 @@
-aiofiles==0.7.0
-anyio==3.3.1
-asgiref==3.4.1
+aiofiles==0.8.0
+anyio==3.6.1
asyncio==3.4.3
-attrs==21.2.0
+attrs==21.4.0
bech32==1.2.0
bitstring==3.1.9
cerberus==1.3.4
-certifi==2021.5.30
-charset-normalizer==2.0.6
-click==8.0.1
-ecdsa==0.17.0
-embit==0.4.9
-environs==9.3.3
-fastapi==0.68.1
+certifi==2022.6.15
+cffi==1.15.0
+click==8.1.3
+ecdsa==0.18.0
+embit==0.5.0
+environs==9.5.0
+fastapi==0.79.0
h11==0.12.0
-httpcore==0.13.7
-httptools==0.2.0
-httpx==0.19.0
-idna==3.2
-importlib-metadata==4.8.1
+httpcore==0.15.0
+httptools==0.4.0
+httpx==0.23.0
+idna==3.3
jinja2==3.0.1
lnurl==0.3.6
-markupsafe==2.0.1
-marshmallow==3.13.0
-outcome==1.1.0
-psycopg2-binary==2.9.1
-pycryptodomex==3.14.1
-pydantic==1.8.2
-pypng==0.0.21
+loguru==0.6.0
+markupsafe==2.1.1
+marshmallow==3.17.0
+outcome==1.2.0
+packaging==21.3
+psycopg2-binary==2.9.3
+pycparser==2.21
+pycryptodomex==3.15.0
+pydantic==1.9.1
+pyngrok==5.1.0
+pyparsing==3.0.9
+pypng==0.20220715.0
pyqrcode==1.2.1
-pyscss==1.3.7
-python-dotenv==0.19.0
-pyyaml==5.4.1
+pyscss==1.4.0
+python-dotenv==0.20.0
+pyyaml==6.0
represent==1.6.0.post0
rfc3986==1.5.0
secp256k1==0.14.0
-shortuuid==1.0.1
+shortuuid==1.0.9
six==1.16.0
sniffio==1.2.0
+sqlalchemy-aio==0.17.0
sqlalchemy==1.3.23
-sqlalchemy-aio==0.16.0
-sse-starlette==0.6.2
-starlette==0.14.2
-typing-extensions==3.10.0.2
-uvicorn==0.15.0
+sse-starlette==0.10.3
+starlette==0.19.1
+typing-extensions==4.3.0
+uvicorn==0.18.2
uvloop==0.16.0
-watchgod==0.7
-websockets==10.0
-zipp==3.5.0
\ No newline at end of file
+watchfiles==0.16.0
+websockets==10.3
diff --git a/result b/result
new file mode 120000
index 00000000..b0acd55c
--- /dev/null
+++ b/result
@@ -0,0 +1 @@
+/nix/store/ds9c48q7hnkdmpzy3aq14kc1x9wrrszd-python3.9-lnbits-0.1.0
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
index 127233c1..adb1fa36 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,28 +1,133 @@
import asyncio
-import pytest
+import pytest_asyncio
+
from httpx import AsyncClient
from lnbits.app import create_app
from lnbits.commands import migrate_databases
from lnbits.settings import HOST, PORT
-import tests.mocks
-# use session scope to run once before and once after all tests
-@pytest.fixture(scope="session")
-def app():
- # yield and pass the app to the test
- app = create_app()
+from lnbits.core.views.api import api_payments_create_invoice, CreateInvoiceData
+
+from lnbits.core.crud import create_account, create_wallet, get_wallet
+from tests.helpers import credit_wallet, get_random_invoice_data
+
+from lnbits.db import Database
+from lnbits.core.models import User, Wallet, Payment, BalanceCheck
+from typing import Tuple
+
+
+@pytest_asyncio.fixture(scope="session")
+def event_loop():
loop = asyncio.get_event_loop()
- loop.run_until_complete(migrate_databases())
- yield app
- # get the current event loop and gracefully stop any running tasks
- loop = asyncio.get_event_loop()
- loop.run_until_complete(loop.shutdown_asyncgens())
+ yield loop
loop.close()
-@pytest.fixture
+
+# use session scope to run once before and once after all tests
+@pytest_asyncio.fixture(scope="session")
+def app(event_loop):
+ app = create_app()
+ # use redefined version of the event loop for scope="session"
+ # loop = asyncio.get_event_loop()
+ loop = event_loop
+ loop.run_until_complete(migrate_databases())
+ yield app
+ # # get the current event loop and gracefully stop any running tasks
+ # loop = event_loop
+ loop.run_until_complete(loop.shutdown_asyncgens())
+ # loop.close()
+
+
+@pytest_asyncio.fixture(scope="session")
async def client(app):
- client = AsyncClient(app=app, base_url=f'http://{HOST}:{PORT}')
- # yield and pass the client to the test
+ client = AsyncClient(app=app, base_url=f"http://{HOST}:{PORT}")
yield client
- # close the async client after the test has finished
await client.aclose()
+
+
+@pytest_asyncio.fixture(scope="session")
+async def db():
+ yield Database("database")
+
+
+@pytest_asyncio.fixture(scope="session")
+async def from_user():
+ user = await create_account()
+ yield user
+
+
+@pytest_asyncio.fixture(scope="session")
+async def from_wallet(from_user):
+ user = from_user
+ wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from")
+ await credit_wallet(
+ wallet_id=wallet.id,
+ amount=99999999,
+ )
+ yield wallet
+
+
+@pytest_asyncio.fixture(scope="session")
+async def to_user():
+ user = await create_account()
+ yield user
+
+
+@pytest_asyncio.fixture(scope="session")
+async def to_wallet(to_user):
+ user = to_user
+ wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to")
+ await credit_wallet(
+ wallet_id=wallet.id,
+ amount=99999999,
+ )
+ yield wallet
+
+
+@pytest_asyncio.fixture(scope="session")
+async def inkey_headers_from(from_wallet):
+ wallet = from_wallet
+ yield {
+ "X-Api-Key": wallet.inkey,
+ "Content-type": "application/json",
+ }
+
+
+@pytest_asyncio.fixture(scope="session")
+async def adminkey_headers_from(from_wallet):
+ wallet = from_wallet
+ yield {
+ "X-Api-Key": wallet.adminkey,
+ "Content-type": "application/json",
+ }
+
+
+@pytest_asyncio.fixture(scope="session")
+async def inkey_headers_to(to_wallet):
+ wallet = to_wallet
+ yield {
+ "X-Api-Key": wallet.inkey,
+ "Content-type": "application/json",
+ }
+
+
+@pytest_asyncio.fixture(scope="session")
+async def adminkey_headers_to(to_wallet):
+ wallet = to_wallet
+ yield {
+ "X-Api-Key": wallet.adminkey,
+ "Content-type": "application/json",
+ }
+
+
+@pytest_asyncio.fixture(scope="session")
+async def invoice(to_wallet):
+ wallet = to_wallet
+ data = await get_random_invoice_data()
+ invoiceData = CreateInvoiceData(**data)
+ stuff_lock = asyncio.Lock()
+ async with stuff_lock:
+ invoice = await api_payments_create_invoice(invoiceData, wallet)
+ await asyncio.sleep(1)
+ yield invoice
+ del invoice
diff --git a/tests/core/views/test_api.py b/tests/core/views/test_api.py
new file mode 100644
index 00000000..dfd2b32a
--- /dev/null
+++ b/tests/core/views/test_api.py
@@ -0,0 +1,181 @@
+import pytest
+import pytest_asyncio
+from lnbits.core.crud import get_wallet
+from lnbits.core.views.api import api_payment
+
+from ...helpers import get_random_invoice_data
+
+# check if the client is working
+@pytest.mark.asyncio
+async def test_core_views_generic(client):
+ response = await client.get("/")
+ assert response.status_code == 200
+
+
+# check GET /api/v1/wallet with inkey: wallet info, no balance
+@pytest.mark.asyncio
+async def test_get_wallet_inkey(client, inkey_headers_to):
+ response = await client.get("/api/v1/wallet", headers=inkey_headers_to)
+ assert response.status_code == 200
+ result = response.json()
+ assert "name" in result
+ assert "balance" in result
+ assert "id" not in result
+
+
+# check GET /api/v1/wallet with adminkey: wallet info with balance
+@pytest.mark.asyncio
+async def test_get_wallet_adminkey(client, adminkey_headers_to):
+ response = await client.get("/api/v1/wallet", headers=adminkey_headers_to)
+ assert response.status_code == 200
+ result = response.json()
+ assert "name" in result
+ assert "balance" in result
+ assert "id" in result
+
+
+# check POST /api/v1/payments: invoice creation
+@pytest.mark.asyncio
+async def test_create_invoice(client, inkey_headers_to):
+ data = await get_random_invoice_data()
+ response = await client.post(
+ "/api/v1/payments", json=data, headers=inkey_headers_to
+ )
+ assert response.status_code == 201
+ invoice = response.json()
+ assert "payment_hash" in invoice
+ assert len(invoice["payment_hash"]) == 64
+ assert "payment_request" in invoice
+ assert "checking_id" in invoice
+ assert len(invoice["checking_id"])
+ return invoice
+
+
+# check POST /api/v1/payments: invoice creation for internal payments only
+@pytest.mark.asyncio
+async def test_create_internal_invoice(client, inkey_headers_to):
+ data = await get_random_invoice_data()
+ data["internal"] = True
+ response = await client.post(
+ "/api/v1/payments", json=data, headers=inkey_headers_to
+ )
+ invoice = response.json()
+ assert response.status_code == 201
+ assert "payment_hash" in invoice
+ assert len(invoice["payment_hash"]) == 64
+ assert "payment_request" in invoice
+ assert "checking_id" in invoice
+ assert len(invoice["checking_id"])
+ return invoice
+
+
+# check POST /api/v1/payments: make payment
+@pytest.mark.asyncio
+async def test_pay_invoice(client, invoice, adminkey_headers_from):
+ data = {"out": True, "bolt11": invoice["payment_request"]}
+ response = await client.post(
+ "/api/v1/payments", json=data, headers=adminkey_headers_from
+ )
+ assert response.status_code < 300
+ assert len(response.json()["payment_hash"]) == 64
+ assert len(response.json()["checking_id"]) > 0
+
+
+# check GET /api/v1/payments/: payment status
+@pytest.mark.asyncio
+async def test_check_payment_without_key(client, invoice):
+ # check the payment status
+ response = await client.get(f"/api/v1/payments/{invoice['payment_hash']}")
+ assert response.status_code < 300
+ assert response.json()["paid"] == True
+ assert invoice
+ # not key, that's why no "details"
+ assert "details" not in response.json()
+
+
+# check GET /api/v1/payments/: payment status
+# NOTE: this test is sensitive to which db is used.
+# If postgres: it will succeed only with inkey_headers_from
+# If sqlite: it will succeed only with adminkey_headers_to
+# TODO: fix this
+@pytest.mark.asyncio
+async def test_check_payment_with_key(client, invoice, inkey_headers_from):
+ # check the payment status
+ response = await client.get(
+ f"/api/v1/payments/{invoice['payment_hash']}", headers=inkey_headers_from
+ )
+ assert response.status_code < 300
+ assert response.json()["paid"] == True
+ assert invoice
+ # with key, that's why with "details"
+ assert "details" in response.json()
+
+
+# check POST /api/v1/payments: payment with wrong key type
+@pytest.mark.asyncio
+async def test_pay_invoice_wrong_key(client, invoice, adminkey_headers_from):
+ data = {"out": True, "bolt11": invoice["payment_request"]}
+ # try payment with wrong key
+ wrong_adminkey_headers = adminkey_headers_from.copy()
+ wrong_adminkey_headers["X-Api-Key"] = "wrong_key"
+ response = await client.post(
+ "/api/v1/payments", json=data, headers=wrong_adminkey_headers
+ )
+ assert response.status_code >= 300 # should fail
+
+
+# check POST /api/v1/payments: payment with invoice key [should fail]
+@pytest.mark.asyncio
+async def test_pay_invoice_invoicekey(client, invoice, inkey_headers_from):
+ data = {"out": True, "bolt11": invoice["payment_request"]}
+ # try payment with invoice key
+ response = await client.post(
+ "/api/v1/payments", json=data, headers=inkey_headers_from
+ )
+ assert response.status_code >= 300 # should fail
+
+
+# check POST /api/v1/payments: payment with admin key [should pass]
+@pytest.mark.asyncio
+async def test_pay_invoice_adminkey(client, invoice, adminkey_headers_from):
+ data = {"out": True, "bolt11": invoice["payment_request"]}
+ # try payment with admin key
+ response = await client.post(
+ "/api/v1/payments", json=data, headers=adminkey_headers_from
+ )
+ assert response.status_code < 300 # should pass
+
+
+# check POST /api/v1/payments/decode
+@pytest.mark.asyncio
+async def test_decode_invoice(client, invoice):
+ data = {"data": invoice["payment_request"]}
+ response = await client.post(
+ "/api/v1/payments/decode",
+ json=data,
+ )
+ assert response.status_code < 300
+ assert response.json()["payment_hash"] == invoice["payment_hash"]
+
+
+# check api_payment() internal function call (NOT API): payment status
+@pytest.mark.asyncio
+async def test_api_payment_without_key(invoice):
+ # check the payment status
+ response = await api_payment(invoice["payment_hash"])
+ assert type(response) == dict
+ assert response["paid"] == True
+ # no key, that's why no "details"
+ assert "details" not in response
+
+
+# check api_payment() internal function call (NOT API): payment status
+@pytest.mark.asyncio
+async def test_api_payment_with_key(invoice, inkey_headers_from):
+ # check the payment status
+ response = await api_payment(
+ invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
+ )
+ assert type(response) == dict
+ assert response["paid"] == True
+ assert "details" in response
diff --git a/tests/core/views/test_generic.py b/tests/core/views/test_generic.py
index 4917cde4..6e6354d1 100644
--- a/tests/core/views/test_generic.py
+++ b/tests/core/views/test_generic.py
@@ -1,7 +1,160 @@
import pytest
+import pytest_asyncio
from tests.conftest import client
+
@pytest.mark.asyncio
async def test_core_views_generic(client):
response = await client.get("/")
- assert response.status_code == 200
+ assert response.status_code == 200, (
+ str(response.url) + " " + str(response.status_code)
+ )
+
+
+# check GET /wallet: wallet info
+@pytest.mark.asyncio
+async def test_get_wallet(client):
+ response = await client.get("wallet")
+ assert response.status_code == 307, ( # redirect not modified
+ str(response.url) + " " + str(response.status_code)
+ )
+
+
+# check GET /wallet: do not allow redirects, expect code 307
+@pytest.mark.asyncio
+async def test_get_wallet_no_redirect(client):
+ response = await client.get("wallet", follow_redirects=False)
+ assert response.status_code == 307, (
+ str(response.url) + " " + str(response.status_code)
+ )
+
+ # determine the next redirect location
+ request = client.build_request("GET", "wallet")
+ i = 0
+ while request is not None:
+ response = await client.send(request)
+ request = response.next_request
+ if i == 0:
+ assert response.status_code == 307, ( # first redirect
+ str(response.url) + " " + str(response.status_code)
+ )
+ elif i == 1:
+ assert response.status_code == 200, ( # then get the actual page
+ str(response.url) + " " + str(response.status_code)
+ )
+ i += 1
+
+
+# check GET /wallet: wrong user, expect 204
+@pytest.mark.asyncio
+async def test_get_wallet_with_nonexistent_user(client):
+ response = await client.get("wallet", params={"usr": "1"})
+ assert response.status_code == 204, (
+ str(response.url) + " " + str(response.status_code)
+ )
+
+
+# check GET /wallet: with user
+@pytest.mark.asyncio
+async def test_get_wallet_with_user(client, to_user):
+ response = await client.get("wallet", params={"usr": to_user.id})
+ assert response.status_code == 307, (
+ str(response.url) + " " + str(response.status_code)
+ )
+
+ # determine the next redirect location
+ request = client.build_request("GET", "wallet", params={"usr": to_user.id})
+ i = 0
+ while request is not None:
+ response = await client.send(request)
+ request = response.next_request
+ if i == 0:
+ assert response.status_code == 307, ( # first redirect
+ str(response.url) + " " + str(response.status_code)
+ )
+ elif i == 1:
+ assert response.status_code == 200, ( # then get the actual page
+ str(response.url) + " " + str(response.status_code)
+ )
+ i += 1
+
+
+# check GET /wallet: wallet and user
+@pytest.mark.asyncio
+async def test_get_wallet_with_user_and_wallet(client, to_user, to_wallet):
+ response = await client.get(
+ "wallet", params={"usr": to_user.id, "wal": to_wallet.id}
+ )
+ assert response.status_code == 200, (
+ str(response.url) + " " + str(response.status_code)
+ )
+
+
+# check GET /wallet: wrong wallet and user, expect 204
+@pytest.mark.asyncio
+async def test_get_wallet_with_user_and_wrong_wallet(client, to_user):
+ response = await client.get("wallet", params={"usr": to_user.id, "wal": "1"})
+ assert response.status_code == 204, (
+ str(response.url) + " " + str(response.status_code)
+ )
+
+
+# check GET /extensions: extensions list
+@pytest.mark.asyncio
+async def test_get_extensions(client, to_user):
+ response = await client.get("extensions", params={"usr": to_user.id})
+ assert response.status_code == 200, (
+ str(response.url) + " " + str(response.status_code)
+ )
+
+
+# check GET /extensions: extensions list wrong user, expect 204
+@pytest.mark.asyncio
+async def test_get_extensions_wrong_user(client, to_user):
+ response = await client.get("extensions", params={"usr": "1"})
+ assert response.status_code == 204, (
+ str(response.url) + " " + str(response.status_code)
+ )
+
+
+# check GET /extensions: no user given, expect code 204 no content
+@pytest.mark.asyncio
+async def test_get_extensions_no_user(client):
+ response = await client.get("extensions")
+ assert response.status_code == 204, ( # no content
+ str(response.url) + " " + str(response.status_code)
+ )
+
+
+# check GET /extensions: enable extension
+@pytest.mark.asyncio
+async def test_get_extensions_enable(client, to_user):
+ response = await client.get(
+ "extensions", params={"usr": to_user.id, "enable": "lnurlp"}
+ )
+ assert response.status_code == 200, (
+ str(response.url) + " " + str(response.status_code)
+ )
+
+
+# check GET /extensions: enable nonexistent extension, expect code 400 bad request
+@pytest.mark.asyncio
+async def test_get_extensions_enable_nonexistent_extension(client, to_user):
+ response = await client.get(
+ "extensions", params={"usr": to_user.id, "enable": "12341234"}
+ )
+ assert response.status_code == 400, (
+ str(response.url) + " " + str(response.status_code)
+ )
+
+
+# check GET /extensions: enable and disable extensions, expect code 400 bad request
+@pytest.mark.asyncio
+async def test_get_extensions_enable_and_disable(client, to_user):
+ response = await client.get(
+ "extensions",
+ params={"usr": to_user.id, "enable": "lnurlp", "disable": "lnurlp"},
+ )
+ assert response.status_code == 400, (
+ str(response.url) + " " + str(response.status_code)
+ )
diff --git a/tests/core/views/test_public_api.py b/tests/core/views/test_public_api.py
new file mode 100644
index 00000000..d9c253c2
--- /dev/null
+++ b/tests/core/views/test_public_api.py
@@ -0,0 +1,37 @@
+import pytest
+import pytest_asyncio
+from lnbits.core.crud import get_wallet
+
+# check if the client is working
+@pytest.mark.asyncio
+async def test_core_views_generic(client):
+ response = await client.get("/")
+ assert response.status_code == 200
+
+
+# check GET /public/v1/payment/{payment_hash}: correct hash [should pass]
+@pytest.mark.asyncio
+async def test_api_public_payment_longpolling(client, invoice):
+ response = await client.get(f"/public/v1/payment/{invoice['payment_hash']}")
+ assert response.status_code < 300
+ assert response.json()["status"] == "paid"
+
+
+# check GET /public/v1/payment/{payment_hash}: wrong hash [should fail]
+@pytest.mark.asyncio
+async def test_api_public_payment_longpolling_wrong_hash(client, invoice):
+ response = await client.get(
+ f"/public/v1/payment/{invoice['payment_hash'] + '0'*64}"
+ )
+ assert response.status_code == 404
+ assert response.json()["detail"] == "Payment does not exist."
+
+
+# check GET /.well-known/lnurlp/{username}: wrong username [should fail]
+@pytest.mark.asyncio
+async def test_lnaddress_wrong_hash(client):
+ username = "wrong_name"
+ response = await client.get(f"/.well-known/lnurlp/{username}")
+ assert response.status_code == 200
+ assert response.json()["status"] == "ERROR"
+ assert response.json()["reason"] == "Address not found."
diff --git a/tests/data/mock_data.zip b/tests/data/mock_data.zip
new file mode 100644
index 00000000..0480adc2
Binary files /dev/null and b/tests/data/mock_data.zip differ
diff --git a/tests/extensions/bleskomat/conftest.py b/tests/extensions/bleskomat/conftest.py
index 924998a7..b4ad0bfc 100644
--- a/tests/extensions/bleskomat/conftest.py
+++ b/tests/extensions/bleskomat/conftest.py
@@ -1,20 +1,27 @@
import json
import pytest
+import pytest_asyncio
import secrets
from lnbits.core.crud import create_account, create_wallet
from lnbits.extensions.bleskomat.crud import create_bleskomat, create_bleskomat_lnurl
from lnbits.extensions.bleskomat.models import CreateBleskomat
-from lnbits.extensions.bleskomat.helpers import generate_bleskomat_lnurl_secret, generate_bleskomat_lnurl_signature, prepare_lnurl_params, query_to_signing_payload
+from lnbits.extensions.bleskomat.helpers import (
+ generate_bleskomat_lnurl_secret,
+ generate_bleskomat_lnurl_signature,
+ prepare_lnurl_params,
+ query_to_signing_payload,
+)
from lnbits.extensions.bleskomat.exchange_rates import exchange_rate_providers
exchange_rate_providers["dummy"] = {
"name": "dummy",
"domain": None,
"api_url": None,
- "getter": lambda data, replacements: str(1e8),# 1 BTC = 100000000 sats
+ "getter": lambda data, replacements: str(1e8), # 1 BTC = 100000000 sats
}
-@pytest.fixture
+
+@pytest_asyncio.fixture
async def bleskomat():
user = await create_account()
wallet = await create_wallet(user_id=user.id, wallet_name="bleskomat_test")
@@ -22,12 +29,13 @@ async def bleskomat():
name="Test Bleskomat",
fiat_currency="EUR",
exchange_rate_provider="dummy",
- fee="0"
+ fee="0",
)
bleskomat = await create_bleskomat(data=data, wallet_id=wallet.id)
return bleskomat
-@pytest.fixture
+
+@pytest_asyncio.fixture
async def lnurl(bleskomat):
query = {
"tag": "withdrawRequest",
@@ -43,7 +51,7 @@ async def lnurl(bleskomat):
signature = generate_bleskomat_lnurl_signature(
payload=payload,
api_key_secret=bleskomat.api_key_secret,
- api_key_encoding=bleskomat.api_key_encoding
+ api_key_encoding=bleskomat.api_key_encoding,
)
secret = generate_bleskomat_lnurl_secret(bleskomat.api_key_id, signature)
params = json.JSONEncoder().encode(params)
diff --git a/tests/extensions/bleskomat/test_lnurl_api.py b/tests/extensions/bleskomat/test_lnurl_api.py
index 00358470..0189e119 100644
--- a/tests/extensions/bleskomat/test_lnurl_api.py
+++ b/tests/extensions/bleskomat/test_lnurl_api.py
@@ -1,4 +1,5 @@
import pytest
+import pytest_asyncio
import secrets
from lnbits.core.crud import get_wallet
from lnbits.settings import HOST, PORT
diff --git a/tests/helpers.py b/tests/helpers.py
index 1687e25d..0ba1963d 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -1,7 +1,10 @@
import hashlib
import secrets
+import random
+import string
from lnbits.core.crud import create_payment
+
async def credit_wallet(wallet_id: str, amount: int):
preimage = secrets.token_hex(32)
m = hashlib.sha256()
@@ -13,7 +16,18 @@ async def credit_wallet(wallet_id: str, amount: int):
payment_hash=payment_hash,
checking_id=payment_hash,
preimage=preimage,
- memo="",
- amount=amount,# msat
- pending=False,# not pending, so it will increase the wallet's balance
+ memo=f"funding_test_{get_random_string(5)}",
+ amount=amount, # msat
+ pending=False, # not pending, so it will increase the wallet's balance
)
+
+
+def get_random_string(N=10):
+ return "".join(
+ random.SystemRandom().choice(string.ascii_uppercase + string.digits)
+ for _ in range(10)
+ )
+
+
+async def get_random_invoice_data():
+ return {"out": False, "amount": 10, "memo": f"test_memo_{get_random_string(10)}"}
diff --git a/tests/mocks.py b/tests/mocks.py
index a3b5308d..c99691cb 100644
--- a/tests/mocks.py
+++ b/tests/mocks.py
@@ -2,27 +2,57 @@ from mock import AsyncMock
from lnbits import bolt11
from lnbits.wallets.base import (
StatusResponse,
- InvoiceResponse,
PaymentResponse,
PaymentStatus,
- Wallet,
)
from lnbits.settings import WALLET
+from lnbits.wallets.fake import FakeWallet
+
+from .helpers import get_random_string
+
+
+# generates an invoice with FakeWallet
+async def generate_mock_invoice(**x):
+ invoice = await FakeWallet.create_invoice(
+ FakeWallet(), amount=10, memo=f"mock invoice {get_random_string()}"
+ )
+ return invoice
+
+
WALLET.status = AsyncMock(
return_value=StatusResponse(
"", # no error
1000000, # msats
)
)
-WALLET.create_invoice = AsyncMock(
- return_value=InvoiceResponse(
- True, # ok
- "6621aafbdd7709ca6eea6203f362d64bd7cb2911baa91311a176b3ecaf2274bd", # checking_id (i.e. payment_hash)
- "lntb1u1psezhgspp5vcs6477awuyu5mh2vgplxckkf0tuk2g3h253xydpw6e7etezwj7sdqqcqzpgxqyz5vqsp5dxpw8zs77hw5pla4wz4mfujllyxtlpu443auur2uxqdrs8q2h56q9qyyssq65zk30ylmygvv5y4tuwalnf3ttnqjn57ef6rmcqg0s53akem560jh8ptemjcmytn3lrlatw4hv9smg88exv3v4f4lqnp96s0psdrhxsp6pp75q", # payment_request
- "", # no error
- )
-)
+
+# Note: if this line is uncommented, invoices will always be generated by FakeWallet
+# WALLET.create_invoice = generate_mock_invoice
+
+# NOTE: This mock fails since it yields the same invoice multiple
+# times which makes the db throw an error due to uniqueness contraints
+# on the checking ID
+
+# # primitive event loop for generate_mock_invoice()
+# def drive(c):
+# while True:
+# try:
+# c.send(None)
+# except StopIteration as e:
+# return e.value
+
+# # finally we await it
+# invoice = drive(generate_mock_invoice())
+
+# WALLET.create_invoice = AsyncMock(
+# return_value=InvoiceResponse(
+# True, # ok
+# invoice.checking_id, # checking_id (i.e. payment_hash)
+# invoice.payment_request, # payment_request
+# "", # no error
+# )
+# )
def pay_invoice_side_effect(
diff --git a/conv.py b/tools/conv.py
similarity index 79%
rename from conv.py
rename to tools/conv.py
index 159c7dc0..99bb54f0 100644
--- a/conv.py
+++ b/tools/conv.py
@@ -1,6 +1,14 @@
import psycopg2
import sqlite3
import os
+import argparse
+
+
+from environs import Env # type: ignore
+
+env = Env()
+env.read_env()
+
# Python script to migrate an LNbits SQLite DB to Postgres
# All credits to @Fritz446 for the awesome work
@@ -10,13 +18,25 @@ import os
# Change these values as needed
+
sqfolder = "data/"
-pgdb = "lnbits"
-pguser = "postgres"
-pgpswd = "yourpassword"
-pghost = "localhost"
-pgport = "5432"
-pgschema = ""
+
+LNBITS_DATABASE_URL = env.str("LNBITS_DATABASE_URL", default=None)
+if LNBITS_DATABASE_URL is None:
+ pgdb = "lnbits"
+ pguser = "lnbits"
+ pgpswd = "postgres"
+ pghost = "localhost"
+ pgport = "5432"
+ pgschema = ""
+else:
+ # parse postgres://lnbits:postgres@localhost:5432/lnbits
+ pgdb = LNBITS_DATABASE_URL.split("/")[-1]
+ pguser = LNBITS_DATABASE_URL.split("@")[0].split(":")[-2][2:]
+ pgpswd = LNBITS_DATABASE_URL.split("@")[0].split(":")[-1]
+ pghost = LNBITS_DATABASE_URL.split("@")[1].split(":")[0]
+ pgport = LNBITS_DATABASE_URL.split("@")[1].split(":")[1].split("/")[0]
+ pgschema = ""
def get_sqlite_cursor(sqdb) -> sqlite3:
@@ -34,8 +54,6 @@ def get_postgres_cursor():
def check_db_versions(sqdb):
sqlite = get_sqlite_cursor(sqdb)
dblite = dict(sqlite.execute("SELECT * FROM dbversions;").fetchall())
- if "lnurlpos" in dblite:
- del dblite["lnurlpos"]
sqlite.close()
postgres = get_postgres_cursor()
@@ -79,8 +97,14 @@ def insert_to_pg(query, data):
for d in data:
try:
cursor.execute(query, d)
- except:
- raise ValueError(f"Failed to insert {d}")
+ except Exception as e:
+ if args.ignore_errors:
+ print(e)
+ print(f"Failed to insert {d}")
+ else:
+ print("query:", query)
+ print("data:", d)
+ raise ValueError(f"Failed to insert {d}")
connection.commit()
cursor.close()
@@ -127,9 +151,14 @@ def migrate_core(sqlite_db_file):
print("Migrated: core")
-def migrate_ext(sqlite_db_file, schema):
- sq = get_sqlite_cursor(sqlite_db_file)
+def migrate_ext(sqlite_db_file, schema, ignore_missing=True):
+ # skip this file it has been moved to ext_lnurldevices.sqlite3
+ if sqlite_db_file == "data/ext_lnurlpos.sqlite3":
+ return
+
+ print(f"Migrating {sqlite_db_file}.{schema}")
+ sq = get_sqlite_cursor(sqlite_db_file)
if schema == "bleskomat":
# BLESKOMAT LNURLS
res = sq.execute("SELECT * FROM bleskomat_lnurls;")
@@ -231,9 +260,11 @@ def migrate_ext(sqlite_db_file, schema):
k1,
open_time,
used,
- usescsv
+ usescsv,
+ webhook_url,
+ custom_url
)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# WITHDRAW HASH CHECK
@@ -291,8 +322,8 @@ def migrate_ext(sqlite_db_file, schema):
# TPOSS
res = sq.execute("SELECT * FROM tposs;")
q = f"""
- INSERT INTO tpos.tposs (id, wallet, name, currency)
- VALUES (%s, %s, %s, %s);
+ INSERT INTO tpos.tposs (id, wallet, name, currency, tip_wallet, tip_options)
+ VALUES (%s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "tipjar":
@@ -487,12 +518,13 @@ def migrate_ext(sqlite_db_file, schema):
wallet,
url,
memo,
+ description,
amount,
time,
remembers,
- extra
+ extras
)
- VALUES (%s, %s, %s, %s, %s, to_timestamp(%s), %s, %s);
+ VALUES (%s, %s, %s, %s, %s, %s, to_timestamp(%s), %s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "offlineshop":
@@ -514,18 +546,18 @@ def migrate_ext(sqlite_db_file, schema):
items = res.fetchall()
insert_to_pg(q, items)
fix_id("offlineshop.items_id_seq", items)
- elif schema == "lnurlpos":
- # LNURLPOSS
- res = sq.execute("SELECT * FROM lnurlposs;")
+ elif schema == "lnurlpos" or schema == "lnurldevice":
+ # lnurldevice
+ res = sq.execute("SELECT * FROM lnurldevices;")
q = f"""
- INSERT INTO lnurlpos.lnurlposs (id, key, title, wallet, currency, timestamp)
- VALUES (%s, %s, %s, %s, %s, to_timestamp(%s));
+ INSERT INTO lnurldevice.lnurldevices (id, key, title, wallet, currency, device, profit, timestamp)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
- # LNURLPOS PAYMENT
- res = sq.execute("SELECT * FROM lnurlpospayment;")
+ # lnurldevice PAYMENT
+ res = sq.execute("SELECT * FROM lnurldevicepayment;")
q = f"""
- INSERT INTO lnurlpos.lnurlpospayment (id, posid, payhash, payload, pin, sats, timestamp)
+ INSERT INTO lnurldevice.lnurldevicepayment (id, deviceid, payhash, payload, pin, sats, timestamp)
VALUES (%s, %s, %s, %s, %s, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
@@ -545,9 +577,10 @@ def migrate_ext(sqlite_db_file, schema):
success_url,
currency,
comment_chars,
- max
+ max,
+ fiat_base_multiplier
)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
"""
pay_links = res.fetchall()
insert_to_pg(q, pay_links)
@@ -636,22 +669,117 @@ def migrate_ext(sqlite_db_file, schema):
tracks = res.fetchall()
insert_to_pg(q, tracks)
fix_id("livestream.tracks_id_seq", tracks)
+ elif schema == "lnaddress":
+ # DOMAINS
+ res = sq.execute("SELECT * FROM domain;")
+ q = f"""
+ INSERT INTO lnaddress.domain(
+ id, wallet, domain, webhook, cf_token, cf_zone_id, cost, "time")
+ VALUES (%s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
+ """
+ insert_to_pg(q, res.fetchall())
+ # ADDRESSES
+ res = sq.execute("SELECT * FROM address;")
+ q = f"""
+ INSERT INTO lnaddress.address(
+ id, wallet, domain, email, username, wallet_key, wallet_endpoint, sats, duration, paid, "time")
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s::boolean, to_timestamp(%s));
+ """
+ insert_to_pg(q, res.fetchall())
+ elif schema == "discordbot":
+ # USERS
+ res = sq.execute("SELECT * FROM users;")
+ q = f"""
+ INSERT INTO discordbot.users(
+ id, name, admin, discord_id)
+ VALUES (%s, %s, %s, %s);
+ """
+ insert_to_pg(q, res.fetchall())
+ # WALLETS
+ res = sq.execute("SELECT * FROM wallets;")
+ q = f"""
+ INSERT INTO discordbot.wallets(
+ id, admin, name, "user", adminkey, inkey)
+ VALUES (%s, %s, %s, %s, %s, %s);
+ """
+ insert_to_pg(q, res.fetchall())
else:
- print(f"Not implemented: {schema}")
+ print(f"❌ Not implemented: {schema}")
sq.close()
+
+ if ignore_missing == False:
+ raise Exception(
+ f"Not implemented: {schema}. Use --ignore-missing to skip missing extensions."
+ )
return
- print(f"Migrated: {schema}")
+ print(f"✅ Migrated: {schema}")
sq.close()
-check_db_versions("data/database.sqlite3")
-migrate_core("data/database.sqlite3")
+parser = argparse.ArgumentParser(
+ description="LNbits migration tool for migrating data from SQLite to PostgreSQL"
+)
+parser.add_argument(
+ dest="sqlite_path",
+ const=True,
+ nargs="?",
+ help=f"SQLite DB folder *or* single extension db file to migrate. Default: {sqfolder}",
+ default=sqfolder,
+ type=str,
+)
+parser.add_argument(
+ "-e",
+ "--extensions-only",
+ help="Migrate only extensions",
+ required=False,
+ default=False,
+ action="store_true",
+)
+
+parser.add_argument(
+ "-s",
+ "--skip-missing",
+ help="Error if migration is missing for an extension",
+ required=False,
+ default=False,
+ action="store_true",
+)
+
+parser.add_argument(
+ "-i",
+ "--ignore-errors",
+ help="Don't error if migration fails",
+ required=False,
+ default=False,
+ action="store_true",
+)
+
+args = parser.parse_args()
+
+print("Selected path: ", args.sqlite_path)
+
+if os.path.isdir(args.sqlite_path):
+ file = os.path.join(args.sqlite_path, "database.sqlite3")
+ check_db_versions(file)
+ if not args.extensions_only:
+ print(f"Migrating: {file}")
+ migrate_core(file)
+
+if os.path.isdir(args.sqlite_path):
+ files = [
+ os.path.join(args.sqlite_path, file) for file in os.listdir(args.sqlite_path)
+ ]
+else:
+ files = [args.sqlite_path]
-files = os.listdir(sqfolder)
for file in files:
- path = f"data/{file}"
- if file.startswith("ext_"):
- schema = file.replace("ext_", "").split(".")[0]
- print(f"Migrating: {schema}")
- migrate_ext(path, schema)
+ filename = os.path.basename(file)
+ if filename.startswith("ext_"):
+ schema = filename.replace("ext_", "").split(".")[0]
+ print(f"Migrating: {file}")
+ migrate_ext(
+ file,
+ schema,
+ ignore_missing=args.skip_missing,
+ )