mirror of
https://github.com/lnbits/lnbits.git
synced 2025-07-09 15:04:10 +02:00
Merge pull request #154 from lnbits/offlineshop
This commit is contained in:
@ -131,14 +131,15 @@ async def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wa
|
|||||||
# ---------------
|
# ---------------
|
||||||
|
|
||||||
|
|
||||||
async def get_standalone_payment(checking_id: str) -> Optional[Payment]:
|
async def get_standalone_payment(checking_id_or_hash: str) -> Optional[Payment]:
|
||||||
row = await db.fetchone(
|
row = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM apipayments
|
FROM apipayments
|
||||||
WHERE checking_id = ?
|
WHERE checking_id = ? OR hash = ?
|
||||||
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(checking_id,),
|
(checking_id_or_hash, checking_id_or_hash),
|
||||||
)
|
)
|
||||||
|
|
||||||
return Payment.from_row(row) if row else None
|
return Payment.from_row(row) if row else None
|
||||||
|
@ -1,48 +0,0 @@
|
|||||||
import trio # type: ignore
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
async def get_fiat_rate(currency: str):
|
|
||||||
assert currency == "USD", "Only USD is supported as fiat currency."
|
|
||||||
return await get_usd_rate()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_usd_rate():
|
|
||||||
"""
|
|
||||||
Returns an average satoshi price from multiple sources.
|
|
||||||
"""
|
|
||||||
|
|
||||||
satoshi_prices = [None, None, None]
|
|
||||||
|
|
||||||
async def fetch_price(index, url, getter):
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
r = await client.get(url)
|
|
||||||
r.raise_for_status()
|
|
||||||
satoshi_price = int(100_000_000 / float(getter(r.json())))
|
|
||||||
satoshi_prices[index] = satoshi_price
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async with trio.open_nursery() as nursery:
|
|
||||||
nursery.start_soon(
|
|
||||||
fetch_price,
|
|
||||||
0,
|
|
||||||
"https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD",
|
|
||||||
lambda d: d["result"]["XXBTCZUSD"]["c"][0],
|
|
||||||
)
|
|
||||||
nursery.start_soon(
|
|
||||||
fetch_price,
|
|
||||||
1,
|
|
||||||
"https://www.bitstamp.net/api/v2/ticker/btcusd",
|
|
||||||
lambda d: d["last"],
|
|
||||||
)
|
|
||||||
nursery.start_soon(
|
|
||||||
fetch_price,
|
|
||||||
2,
|
|
||||||
"https://api.coincap.io/v2/rates/bitcoin",
|
|
||||||
lambda d: d["data"]["rateUsd"],
|
|
||||||
)
|
|
||||||
|
|
||||||
satoshi_prices = [x for x in satoshi_prices if x]
|
|
||||||
return sum(satoshi_prices) / len(satoshi_prices)
|
|
@ -5,10 +5,10 @@ from quart import jsonify, url_for, request
|
|||||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||||
|
|
||||||
from lnbits.core.services import create_invoice
|
from lnbits.core.services import create_invoice
|
||||||
|
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
|
||||||
|
|
||||||
from . import lnurlp_ext
|
from . import lnurlp_ext
|
||||||
from .crud import increment_pay_link
|
from .crud import increment_pay_link
|
||||||
from .helpers import get_fiat_rate
|
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
|
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
|
||||||
@ -17,7 +17,7 @@ async def api_lnurl_response(link_id):
|
|||||||
if not link:
|
if not link:
|
||||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||||
|
|
||||||
rate = await get_fiat_rate(link.currency) if link.currency else 1
|
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||||
resp = LnurlPayResponse(
|
resp = LnurlPayResponse(
|
||||||
callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True),
|
callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True),
|
||||||
min_sendable=math.ceil(link.min * rate) * 1000,
|
min_sendable=math.ceil(link.min * rate) * 1000,
|
||||||
@ -39,7 +39,7 @@ async def api_lnurl_callback(link_id):
|
|||||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||||
|
|
||||||
min, max = link.min, link.max
|
min, max = link.min, link.max
|
||||||
rate = await get_fiat_rate(link.currency) if link.currency else 1
|
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||||
if link.currency:
|
if link.currency:
|
||||||
# allow some fluctuation (as the fiat price may have changed between the calls)
|
# allow some fluctuation (as the fiat price may have changed between the calls)
|
||||||
min = rate * 995 * link.min
|
min = rate * 995 * link.min
|
||||||
|
@ -26,6 +26,7 @@ new Vue({
|
|||||||
mixins: [windowMixin],
|
mixins: [windowMixin],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
currencies: [],
|
||||||
fiatRates: {},
|
fiatRates: {},
|
||||||
checker: null,
|
checker: null,
|
||||||
payLinks: [],
|
payLinks: [],
|
||||||
@ -203,5 +204,14 @@ new Vue({
|
|||||||
getPayLinks()
|
getPayLinks()
|
||||||
}, 20000)
|
}, 20000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/lnurlp/api/v1/currencies')
|
||||||
|
.then(response => {
|
||||||
|
this.currencies = ['satoshis', ...response.data]
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -133,7 +133,7 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||||
<q-select
|
<q-select
|
||||||
@ -182,7 +182,7 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<q-select
|
<q-select
|
||||||
dense
|
dense
|
||||||
:options='["satoshis", "USD"]'
|
:options="currencies"
|
||||||
v-model="formDialog.data.currency"
|
v-model="formDialog.data.currency"
|
||||||
:display-value="formDialog.data.currency || 'satoshis'"
|
:display-value="formDialog.data.currency || 'satoshis'"
|
||||||
label="Currency"
|
label="Currency"
|
||||||
|
@ -4,6 +4,7 @@ from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
|||||||
|
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||||
|
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
|
||||||
|
|
||||||
from . import lnurlp_ext
|
from . import lnurlp_ext
|
||||||
from .crud import (
|
from .crud import (
|
||||||
@ -13,7 +14,11 @@ from .crud import (
|
|||||||
update_pay_link,
|
update_pay_link,
|
||||||
delete_pay_link,
|
delete_pay_link,
|
||||||
)
|
)
|
||||||
from .helpers import get_fiat_rate
|
|
||||||
|
|
||||||
|
@lnurlp_ext.route("/api/v1/currencies", methods=["GET"])
|
||||||
|
async def api_list_currencies_available():
|
||||||
|
return jsonify(list(currencies.keys()))
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
|
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
|
||||||
@ -58,7 +63,7 @@ async def api_link_retrieve(link_id):
|
|||||||
"description": {"type": "string", "empty": False, "required": True},
|
"description": {"type": "string", "empty": False, "required": True},
|
||||||
"min": {"type": "number", "min": 0.01, "required": True},
|
"min": {"type": "number", "min": 0.01, "required": True},
|
||||||
"max": {"type": "number", "min": 0.01, "required": True},
|
"max": {"type": "number", "min": 0.01, "required": True},
|
||||||
"currency": {"type": "string", "allowed": ["USD"], "nullable": True, "required": False},
|
"currency": {"type": "string", "nullable": True, "required": False},
|
||||||
"comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800},
|
"comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800},
|
||||||
"webhook_url": {"type": "string", "required": False},
|
"webhook_url": {"type": "string", "required": False},
|
||||||
"success_text": {"type": "string", "required": False},
|
"success_text": {"type": "string", "required": False},
|
||||||
@ -109,7 +114,7 @@ async def api_link_delete(link_id):
|
|||||||
@lnurlp_ext.route("/api/v1/rate/<currency>", methods=["GET"])
|
@lnurlp_ext.route("/api/v1/rate/<currency>", methods=["GET"])
|
||||||
async def api_check_fiat_rate(currency):
|
async def api_check_fiat_rate(currency):
|
||||||
try:
|
try:
|
||||||
rate = await get_fiat_rate(currency)
|
rate = await get_fiat_rate_satoshis(currency)
|
||||||
except AssertionError:
|
except AssertionError:
|
||||||
rate = None
|
rate = None
|
||||||
|
|
||||||
|
1
lnbits/extensions/offlineshop/README.md
Normal file
1
lnbits/extensions/offlineshop/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Offline Shop
|
12
lnbits/extensions/offlineshop/__init__.py
Normal file
12
lnbits/extensions/offlineshop/__init__.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from quart import Blueprint
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
|
||||||
|
db = Database("ext_offlineshop")
|
||||||
|
|
||||||
|
offlineshop_ext: Blueprint = Blueprint("offlineshop", __name__, static_folder="static", template_folder="templates")
|
||||||
|
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
||||||
|
from .lnurl import * # noqa
|
8
lnbits/extensions/offlineshop/config.json
Normal file
8
lnbits/extensions/offlineshop/config.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "OfflineShop",
|
||||||
|
"short_description": "Sell stuff with Lightning and lnurlpay on a shop without internet or any electronic device.",
|
||||||
|
"icon": "nature_people",
|
||||||
|
"contributors": [
|
||||||
|
"fiatjaf"
|
||||||
|
]
|
||||||
|
}
|
101
lnbits/extensions/offlineshop/crud.py
Normal file
101
lnbits/extensions/offlineshop/crud.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .wordlists import animals
|
||||||
|
from .models import Shop, Item
|
||||||
|
|
||||||
|
|
||||||
|
async def create_shop(*, wallet_id: str) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO shops (wallet, wordlist)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(wallet_id, "\n".join(animals)),
|
||||||
|
)
|
||||||
|
return result._result_proxy.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
async def get_shop(id: int) -> Optional[Shop]:
|
||||||
|
row = await db.fetchone("SELECT * FROM shops WHERE id = ?", (id,))
|
||||||
|
return Shop(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]:
|
||||||
|
row = await db.fetchone("SELECT * FROM shops WHERE wallet = ?", (wallet,))
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
# create on the fly
|
||||||
|
ls_id = await create_shop(wallet_id=wallet)
|
||||||
|
return await get_shop(ls_id)
|
||||||
|
|
||||||
|
return Shop(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]:
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE shops SET method = ?, wordlist = ? WHERE id = ?",
|
||||||
|
(method, wordlist, shop),
|
||||||
|
)
|
||||||
|
return await get_shop(shop)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_item(
|
||||||
|
shop: int,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
image: Optional[str],
|
||||||
|
price: int,
|
||||||
|
unit: str,
|
||||||
|
) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO items (shop, name, description, image, price, unit)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(shop, name, description, image, price, unit),
|
||||||
|
)
|
||||||
|
return result._result_proxy.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
async def update_item(
|
||||||
|
shop: int,
|
||||||
|
item_id: int,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
image: Optional[str],
|
||||||
|
price: int,
|
||||||
|
unit: str,
|
||||||
|
) -> int:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items SET
|
||||||
|
name = ?,
|
||||||
|
description = ?,
|
||||||
|
image = ?,
|
||||||
|
price = ?,
|
||||||
|
unit = ?
|
||||||
|
WHERE shop = ? AND id = ?
|
||||||
|
""",
|
||||||
|
(name, description, image, price, unit, shop, item_id),
|
||||||
|
)
|
||||||
|
return item_id
|
||||||
|
|
||||||
|
|
||||||
|
async def get_item(id: int) -> Optional[Item]:
|
||||||
|
row = await db.fetchone("SELECT * FROM items WHERE id = ? LIMIT 1", (id,))
|
||||||
|
return Item(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_items(shop: int) -> List[Item]:
|
||||||
|
rows = await db.fetchall("SELECT * FROM items WHERE shop = ?", (shop,))
|
||||||
|
return [Item(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_item_from_shop(shop: int, item_id: int):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM items WHERE shop = ? AND id = ?
|
||||||
|
""",
|
||||||
|
(shop, item_id),
|
||||||
|
)
|
17
lnbits/extensions/offlineshop/helpers.py
Normal file
17
lnbits/extensions/offlineshop/helpers.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import base64
|
||||||
|
import struct
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
return str(binary)[-digits:].zfill(digits)
|
||||||
|
|
||||||
|
|
||||||
|
def totp(key, time_step=30, digits=6, digest="sha1"):
|
||||||
|
return hotp(key, int(time.time() / time_step), digits, digest)
|
69
lnbits/extensions/offlineshop/lnurl.py
Normal file
69
lnbits/extensions/offlineshop/lnurl.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import hashlib
|
||||||
|
from quart import jsonify, url_for, request
|
||||||
|
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||||
|
|
||||||
|
from lnbits.core.services import create_invoice
|
||||||
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
|
|
||||||
|
from . import offlineshop_ext
|
||||||
|
from .crud import get_shop, get_item
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
|
||||||
|
async def lnurl_response(item_id):
|
||||||
|
item = await get_item(item_id)
|
||||||
|
if not item:
|
||||||
|
return jsonify({"status": "ERROR", "reason": "Item not found."})
|
||||||
|
|
||||||
|
if not item.enabled:
|
||||||
|
return jsonify({"status": "ERROR", "reason": "Item disabled."})
|
||||||
|
|
||||||
|
price_msat = (await fiat_amount_as_satoshis(item.price, item.unit) if item.unit != "sat" else item.price) * 1000
|
||||||
|
|
||||||
|
resp = LnurlPayResponse(
|
||||||
|
callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True),
|
||||||
|
min_sendable=price_msat,
|
||||||
|
max_sendable=price_msat,
|
||||||
|
metadata=await item.lnurlpay_metadata(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(resp.dict())
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/lnurl/cb/<item_id>", methods=["GET"])
|
||||||
|
async def lnurl_callback(item_id):
|
||||||
|
item = await get_item(item_id)
|
||||||
|
if not item:
|
||||||
|
return jsonify({"status": "ERROR", "reason": "Couldn't find item."})
|
||||||
|
|
||||||
|
if item.unit == "sat":
|
||||||
|
min = item.price * 1000
|
||||||
|
max = item.price * 1000
|
||||||
|
else:
|
||||||
|
price = await fiat_amount_as_satoshis(item.price, item.unit)
|
||||||
|
# allow some fluctuation (the fiat price may have changed between the calls)
|
||||||
|
min = price * 995
|
||||||
|
max = price * 1010
|
||||||
|
|
||||||
|
amount_received = int(request.args.get("amount"))
|
||||||
|
if amount_received < min:
|
||||||
|
return jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is smaller than minimum {min}.").dict())
|
||||||
|
elif amount_received > max:
|
||||||
|
return jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict())
|
||||||
|
|
||||||
|
shop = await get_shop(item.shop)
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=shop.wallet,
|
||||||
|
amount=int(amount_received / 1000),
|
||||||
|
memo=item.name,
|
||||||
|
description_hash=hashlib.sha256((await item.lnurlpay_metadata()).encode("utf-8")).digest(),
|
||||||
|
extra={"tag": "offlineshop", "item": item.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = LnurlPayActionResponse(
|
||||||
|
pr=payment_request,
|
||||||
|
success_action=item.success_action(shop, payment_hash) if shop.method else None,
|
||||||
|
routes=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(resp.dict())
|
29
lnbits/extensions/offlineshop/migrations.py
Normal file
29
lnbits/extensions/offlineshop/migrations.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial offlineshop tables.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE shops (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
wordlist TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE items (
|
||||||
|
shop INTEGER NOT NULL REFERENCES shop (id),
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
image TEXT, -- image/png;base64,...
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
price INTEGER NOT NULL,
|
||||||
|
unit TEXT NOT NULL DEFAULT 'sat'
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
114
lnbits/extensions/offlineshop/models.py
Normal file
114
lnbits/extensions/offlineshop/models.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from collections import OrderedDict
|
||||||
|
from quart import url_for
|
||||||
|
from typing import NamedTuple, 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 .helpers import totp
|
||||||
|
|
||||||
|
shop_counters: Dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ShopCounter(object):
|
||||||
|
fulfilled_payments: OrderedDict
|
||||||
|
counter: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def invoke(cls, shop: "Shop"):
|
||||||
|
shop_counter = shop_counters.get(shop.id)
|
||||||
|
if not shop_counter:
|
||||||
|
shop_counter = cls(wordlist=shop.wordlist.split("\n"))
|
||||||
|
shop_counters[shop.id] = shop_counter
|
||||||
|
return shop_counter
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def reset(cls, shop: "Shop"):
|
||||||
|
shop_counter = cls.invoke(shop)
|
||||||
|
shop_counter.counter = -1
|
||||||
|
shop_counter.wordlist = shop.wordlist.split("\n")
|
||||||
|
|
||||||
|
def __init__(self, wordlist: List[str]):
|
||||||
|
self.wordlist = wordlist
|
||||||
|
self.fulfilled_payments = OrderedDict()
|
||||||
|
self.counter = -1
|
||||||
|
|
||||||
|
def get_word(self, payment_hash):
|
||||||
|
if payment_hash in self.fulfilled_payments:
|
||||||
|
return self.fulfilled_payments[payment_hash]
|
||||||
|
|
||||||
|
# get a new word
|
||||||
|
self.counter += 1
|
||||||
|
word = self.wordlist[self.counter % len(self.wordlist)]
|
||||||
|
self.fulfilled_payments[payment_hash] = word
|
||||||
|
|
||||||
|
# cleanup confirmation words cache
|
||||||
|
to_remove = len(self.fulfilled_payments) - 23
|
||||||
|
if to_remove > 0:
|
||||||
|
for i in range(to_remove):
|
||||||
|
self.fulfilled_payments.popitem(False)
|
||||||
|
|
||||||
|
return word
|
||||||
|
|
||||||
|
|
||||||
|
class Shop(NamedTuple):
|
||||||
|
id: int
|
||||||
|
wallet: str
|
||||||
|
method: str
|
||||||
|
wordlist: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def otp_key(self) -> str:
|
||||||
|
return base64.b32encode(
|
||||||
|
hashlib.sha256(
|
||||||
|
("otpkey" + str(self.id) + self.wallet).encode("ascii"),
|
||||||
|
).digest()
|
||||||
|
).decode("ascii")
|
||||||
|
|
||||||
|
def get_code(self, payment_hash: str) -> str:
|
||||||
|
if self.method == "wordlist":
|
||||||
|
sc = ShopCounter.invoke(self)
|
||||||
|
return sc.get_word(payment_hash)
|
||||||
|
elif self.method == "totp":
|
||||||
|
return totp(self.otp_key)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class Item(NamedTuple):
|
||||||
|
shop: int
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
image: str
|
||||||
|
enabled: bool
|
||||||
|
price: int
|
||||||
|
unit: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lnurl(self) -> str:
|
||||||
|
return lnurl_encode(url_for("offlineshop.lnurl_response", item_id=self.id, _external=True))
|
||||||
|
|
||||||
|
def values(self):
|
||||||
|
values = self._asdict()
|
||||||
|
values["lnurl"] = self.lnurl
|
||||||
|
return values
|
||||||
|
|
||||||
|
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||||
|
metadata = [["text/plain", self.description]]
|
||||||
|
|
||||||
|
if self.image:
|
||||||
|
metadata.append(self.image.split(":")[1].split(","))
|
||||||
|
|
||||||
|
return LnurlPayMetadata(json.dumps(metadata))
|
||||||
|
|
||||||
|
def success_action(self, shop: Shop, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
|
||||||
|
if not shop.wordlist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return UrlAction(
|
||||||
|
url=url_for("offlineshop.confirmation_code", p=payment_hash, _external=True),
|
||||||
|
description="Open to get the confirmation code for your purchase.",
|
||||||
|
)
|
220
lnbits/extensions/offlineshop/static/js/index.js
Normal file
220
lnbits/extensions/offlineshop/static/js/index.js
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||||
|
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
const pica = window.pica()
|
||||||
|
|
||||||
|
const defaultItemData = {
|
||||||
|
unit: 'sat'
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedWallet: null,
|
||||||
|
confirmationMethod: 'wordlist',
|
||||||
|
wordlistTainted: false,
|
||||||
|
offlineshop: {
|
||||||
|
method: null,
|
||||||
|
wordlist: [],
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
itemDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {...defaultItemData},
|
||||||
|
units: ['sat']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
printItems() {
|
||||||
|
return this.offlineshop.items.filter(({enabled}) => enabled)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
openNewDialog() {
|
||||||
|
this.itemDialog.show = true
|
||||||
|
this.itemDialog.data = {...defaultItemData}
|
||||||
|
},
|
||||||
|
openUpdateDialog(itemId) {
|
||||||
|
this.itemDialog.show = true
|
||||||
|
let item = this.offlineshop.items.find(item => item.id === itemId)
|
||||||
|
this.itemDialog.data = item
|
||||||
|
},
|
||||||
|
imageAdded(file) {
|
||||||
|
let blobURL = URL.createObjectURL(file)
|
||||||
|
let image = new Image()
|
||||||
|
image.src = blobURL
|
||||||
|
image.onload = async () => {
|
||||||
|
let canvas = document.createElement('canvas')
|
||||||
|
canvas.setAttribute('width', 100)
|
||||||
|
canvas.setAttribute('height', 100)
|
||||||
|
await pica.resize(image, canvas, {
|
||||||
|
quality: 0,
|
||||||
|
alpha: true,
|
||||||
|
unsharpAmount: 95,
|
||||||
|
unsharpRadius: 0.9,
|
||||||
|
unsharpThreshold: 70
|
||||||
|
})
|
||||||
|
this.itemDialog.data.image = canvas.toDataURL()
|
||||||
|
this.itemDialog = {...this.itemDialog}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
imageCleared() {
|
||||||
|
this.itemDialog.data.image = null
|
||||||
|
this.itemDialog = {...this.itemDialog}
|
||||||
|
},
|
||||||
|
disabledAddItemButton() {
|
||||||
|
return (
|
||||||
|
!this.itemDialog.data.name ||
|
||||||
|
this.itemDialog.data.name.length === 0 ||
|
||||||
|
!this.itemDialog.data.price ||
|
||||||
|
!this.itemDialog.data.description ||
|
||||||
|
!this.itemDialog.data.unit ||
|
||||||
|
this.itemDialog.data.unit.length === 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
changedWallet(wallet) {
|
||||||
|
this.selectedWallet = wallet
|
||||||
|
this.loadShop()
|
||||||
|
},
|
||||||
|
loadShop() {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/offlineshop/api/v1/offlineshop',
|
||||||
|
this.selectedWallet.inkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.offlineshop = response.data
|
||||||
|
this.confirmationMethod = response.data.method
|
||||||
|
this.wordlistTainted = false
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async setMethod() {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/offlineshop/api/v1/offlineshop/method',
|
||||||
|
this.selectedWallet.inkey,
|
||||||
|
{method: this.confirmationMethod, wordlist: this.offlineshop.wordlist}
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$q.notify({
|
||||||
|
message:
|
||||||
|
`Method set to ${this.confirmationMethod}.` +
|
||||||
|
(this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''),
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
this.loadShop()
|
||||||
|
},
|
||||||
|
async sendItem() {
|
||||||
|
let {id, name, image, description, price, unit} = this.itemDialog.data
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
price,
|
||||||
|
unit
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (id) {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/offlineshop/api/v1/offlineshop/items/' + id,
|
||||||
|
this.selectedWallet.inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/offlineshop/api/v1/offlineshop/items',
|
||||||
|
this.selectedWallet.inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
this.$q.notify({
|
||||||
|
message: `Item '${this.itemDialog.data.name}' added.`,
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadShop()
|
||||||
|
this.itemDialog.show = false
|
||||||
|
this.itemDialog.data = {...defaultItemData}
|
||||||
|
},
|
||||||
|
toggleItem(itemId) {
|
||||||
|
let item = this.offlineshop.items.find(item => item.id === itemId)
|
||||||
|
item.enabled = !item.enabled
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/offlineshop/api/v1/offlineshop/items/' + itemId,
|
||||||
|
this.selectedWallet.inkey,
|
||||||
|
item
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.$q.notify({
|
||||||
|
message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`,
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
this.offlineshop.items = this.offlineshop.items
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteItem(itemId) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this item?')
|
||||||
|
.onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/offlineshop/api/v1/offlineshop/items/' + itemId,
|
||||||
|
this.selectedWallet.inkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.$q.notify({
|
||||||
|
message: `Item deleted.`,
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
|
this.offlineshop.items.splice(
|
||||||
|
this.offlineshop.items.findIndex(item => item.id === itemId),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.selectedWallet = this.g.user.wallets[0]
|
||||||
|
this.loadShop()
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/offlineshop/api/v1/currencies')
|
||||||
|
.then(response => {
|
||||||
|
this.itemDialog = {...this.itemDialog, units: ['sat', ...response.data]}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
@ -0,0 +1,147 @@
|
|||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="How to use"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<ol>
|
||||||
|
<li>Register items.</li>
|
||||||
|
<li>
|
||||||
|
Print QR codes and paste them on your store, your menu, somewhere,
|
||||||
|
somehow.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Clients scan the QR codes and get information about the items plus the
|
||||||
|
price on their phones directly (they must have internet)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Once they decide to pay, they'll get an invoice on their phones
|
||||||
|
automatically
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
When the payment is confirmed, a confirmation code will be issued for
|
||||||
|
them.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
The confirmation codes are words from a predefined sequential word list.
|
||||||
|
Each new payment bumps the words sequence by 1. So you can check the
|
||||||
|
confirmation codes manually by just looking at them.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For example, if your wordlist is
|
||||||
|
<code>[apple, banana, coconut]</code> the first purchase will be
|
||||||
|
<code>apple</code>, the second <code>banana</code> and so on. When it
|
||||||
|
gets to the end it starts from the beginning again.
|
||||||
|
</p>
|
||||||
|
<p>Powered by LNURL-pay.</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Create item (a shop will be created automatically based on the wallet you use)"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">POST</span></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 OK</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root
|
||||||
|
}}/offlineshop/api/v1/offlineshop/items -H "Content-Type:
|
||||||
|
application/json" -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -d
|
||||||
|
'{"name": <string>, "description": <string>, "image":
|
||||||
|
<data-uri string>, "price": <integer>, "unit": <"sat"
|
||||||
|
or "USD">}'
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Get the shop data along with items"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">GET</span></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"id": <integer>, "wallet": <string>, "wordlist":
|
||||||
|
<string>, "items": [{"id": <integer>, "name":
|
||||||
|
<string>, "description": <string>, "image":
|
||||||
|
<string>, "enabled": <boolean>, "price": <integer>,
|
||||||
|
"unit": <string>, "lnurl": <string>}, ...]}<</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H
|
||||||
|
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Update item (all fields must be sent again)"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">PUT</span></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root
|
||||||
|
}}/offlineshop/api/v1/offlineshop/items/<item_id> -H
|
||||||
|
"Content-Type: application/json" -H "X-Api-Key: {{
|
||||||
|
g.user.wallets[0].inkey }}" -d '{"name": <string>,
|
||||||
|
"description": <string>, "image": <data-uri string>,
|
||||||
|
"price": <integer>, "unit": <"sat" or "USD">}'
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Delete item">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">DELETE</span></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root
|
||||||
|
}}/offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key:
|
||||||
|
{{ g.user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
332
lnbits/extensions/offlineshop/templates/offlineshop/index.html
Normal file
332
lnbits/extensions/offlineshop/templates/offlineshop/index.html
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Items</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-lg">
|
||||||
|
<q-btn unelevated color="deep-purple" @click="openNewDialog()"
|
||||||
|
>Add new item</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% raw %}
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
selection="multiple"
|
||||||
|
:data="offlineshop.items"
|
||||||
|
row-key="id"
|
||||||
|
no-data-label="No items for sale yet"
|
||||||
|
:pagination="{rowsPerPage: 0}"
|
||||||
|
:binary-state-sort="true"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width>Name</q-th>
|
||||||
|
<q-th auto-width>Description</q-th>
|
||||||
|
<q-th auto-width>Image</q-th>
|
||||||
|
<q-th auto-width>Price</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
:icon="props.row.enabled ? 'done' : 'block'"
|
||||||
|
:color="props.row.enabled ? 'green' : ($q.dark.isActive ? 'grey-7' : 'grey-5')"
|
||||||
|
type="a"
|
||||||
|
@click="toggleItem(props.row.id)"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width class="text-center">{{ props.row.name }}</q-td>
|
||||||
|
<q-td auto-width> {{ props.row.description }} </q-td>
|
||||||
|
<q-td class="text-center" auto-width>
|
||||||
|
<img
|
||||||
|
v-if="props.row.image"
|
||||||
|
:src="props.row.image"
|
||||||
|
style="height: 1.5em"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
<q-td class="text-center" auto-width>
|
||||||
|
{{ props.row.price }} {{ props.row.unit }}
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="openUpdateDialog(props.row.id)"
|
||||||
|
icon="edit"
|
||||||
|
color="light-blue"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="delete"
|
||||||
|
color="negative"
|
||||||
|
type="a"
|
||||||
|
@click="deleteItem(props.row.id)"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card class="q-pa-sm col-5">
|
||||||
|
<q-card-section class="q-pa-none text-center">
|
||||||
|
<div class="row">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Wallet Shop</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
:options="g.user.wallets"
|
||||||
|
:value="selectedWallet"
|
||||||
|
label="Using wallet:"
|
||||||
|
option-label="name"
|
||||||
|
@input="changedWallet"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</q-form>
|
||||||
|
|
||||||
|
<div v-if="printItems.length > 0" class="row q-gutter-sm q-my-md">
|
||||||
|
<q-btn
|
||||||
|
type="a"
|
||||||
|
outline
|
||||||
|
color="purple"
|
||||||
|
:href="'print?items=' + printItems.map(({id}) => id).join(',')"
|
||||||
|
>Print QR Codes</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card class="q-pa-sm col-5">
|
||||||
|
<q-tabs
|
||||||
|
v-model="confirmationMethod"
|
||||||
|
no-caps
|
||||||
|
class="bg-purple text-white shadow-2"
|
||||||
|
>
|
||||||
|
<q-tab name="wordlist" label="Wordlist"></q-tab>
|
||||||
|
<q-tab name="totp" label="TOTP (Google Authenticator)"></q-tab>
|
||||||
|
<q-tab name="none" label="Nothing"></q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
|
||||||
|
<q-card-section class="q-py-sm text-center">
|
||||||
|
<q-form
|
||||||
|
v-if="confirmationMethod === 'wordlist'"
|
||||||
|
class="q-gutter-md q-y-md"
|
||||||
|
@submit="setMethod"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col q-mx-lg">
|
||||||
|
<q-input
|
||||||
|
v-model="offlineshop.wordlist"
|
||||||
|
@input="wordlistTainted = true"
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
autogrow
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="col q-mx-lg items-align flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
type="submit"
|
||||||
|
:disabled="!wordlistTainted"
|
||||||
|
>
|
||||||
|
Update Wordlist
|
||||||
|
</q-btn>
|
||||||
|
<q-btn @click="loadShop" flat color="grey" class="q-ml-auto"
|
||||||
|
>Reset</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
|
||||||
|
<div v-else-if="confirmationMethod === 'totp'">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col q-mx-lg">
|
||||||
|
<q-responsive :ratio="1">
|
||||||
|
<qrcode
|
||||||
|
:value="`otpauth://totp/offlineshop:${selectedWallet.name}?secret=${offlineshop.otp_key}`"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="col q-mx-lg items-align flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
:disabled="offlineshop.method === 'totp'"
|
||||||
|
@click="setMethod"
|
||||||
|
>
|
||||||
|
Set TOTP
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="confirmationMethod === 'none'">
|
||||||
|
<p>
|
||||||
|
Setting this option disables the confirmation code message that
|
||||||
|
appears in the consumer wallet after a purchase is paid for. It's ok
|
||||||
|
if the consumer is to be trusted when they claim to have paid.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
:disabled="offlineshop.method === 'none'"
|
||||||
|
@click="setMethod"
|
||||||
|
>
|
||||||
|
Disable Confirmation Codes
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">LNbits OfflineShop extension</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "offlineshop/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="itemDialog.show">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-card-section>
|
||||||
|
<h5
|
||||||
|
class="q-ma-none"
|
||||||
|
v-if="itemDialog.data.id"
|
||||||
|
v-text="itemDialog.data.name"
|
||||||
|
></h5>
|
||||||
|
<h5 class="q-ma-none q-mb-xl" v-else>Adding a new item</h5>
|
||||||
|
|
||||||
|
<q-responsive v-if="itemDialog.data.id" :ratio="1">
|
||||||
|
<qrcode
|
||||||
|
:value="itemDialog.data.lnurl"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
|
||||||
|
<div v-if="itemDialog.data.id" class="row q-gutter-sm justify-center">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(itemDialog.data.lnurl, 'LNURL copied to clipboard!')"
|
||||||
|
class="q-mb-lg"
|
||||||
|
>Copy LNURL</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<q-form @submit="sendItem" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="itemDialog.data.name"
|
||||||
|
type="text"
|
||||||
|
label="Item name"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="itemDialog.data.description"
|
||||||
|
type="text"
|
||||||
|
label="Brief description"
|
||||||
|
></q-input>
|
||||||
|
<q-file
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
capture="environment"
|
||||||
|
accept="image/jpeg, image/png"
|
||||||
|
:max-file-size="3*1024**2"
|
||||||
|
label="Small image (optional)"
|
||||||
|
clearable
|
||||||
|
@input="imageAdded"
|
||||||
|
@clear="imageCleared"
|
||||||
|
>
|
||||||
|
<template v-if="itemDialog.data.image" v-slot:before>
|
||||||
|
<img style="height: 1em" :src="itemDialog.data.image" />
|
||||||
|
</template>
|
||||||
|
<template v-if="itemDialog.data.image" v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
name="cancel"
|
||||||
|
@click.stop.prevent="imageCleared"
|
||||||
|
class="cursor-pointer"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-file>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="itemDialog.data.price"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:label="`Item price (${itemDialog.data.unit})`"
|
||||||
|
></q-input>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="itemDialog.data.unit"
|
||||||
|
type="text"
|
||||||
|
label="Unit"
|
||||||
|
:options="itemDialog.units"
|
||||||
|
></q-select>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="col q-ml-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
:disable="disabledAddItemButton()"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %}
|
||||||
|
Item
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-lg">
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
|
||||||
|
<script src="/offlineshop/static/js/index.js"></script>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,25 @@
|
|||||||
|
{% extends "print.html" %} {% block page %} {% raw %}
|
||||||
|
<div class="row justify-center">
|
||||||
|
<div v-for="item in items" class="q-my-sm q-mx-lg">
|
||||||
|
<div class="text-center q-ma-none q-mb-sm">{{ item.name }}</div>
|
||||||
|
<qrcode :value="item.lnurl" :options="{margin: 0, width: 250}"></qrcode>
|
||||||
|
<div class="text-center q-ma-none q-mt-sm">{{ item.price }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endraw %} {% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
created: function () {
|
||||||
|
window.print()
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
items: JSON.parse('{{items | tojson}}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
60
lnbits/extensions/offlineshop/views.py
Normal file
60
lnbits/extensions/offlineshop/views.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from quart import g, render_template, request
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from lnbits.decorators import check_user_exists, validate_uuids
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.core.crud import get_standalone_payment
|
||||||
|
|
||||||
|
from . import offlineshop_ext
|
||||||
|
from .crud import get_item, get_shop
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/")
|
||||||
|
@validate_uuids(["usr"], required=True)
|
||||||
|
@check_user_exists()
|
||||||
|
async def index():
|
||||||
|
return await render_template("offlineshop/index.html", user=g.user)
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/print")
|
||||||
|
async def print_qr_codes():
|
||||||
|
items = []
|
||||||
|
for item_id in request.args.get("items").split(","):
|
||||||
|
item = await get_item(item_id)
|
||||||
|
if item:
|
||||||
|
items.append({"lnurl": item.lnurl, "name": item.name, "price": f"{item.price} {item.unit}"})
|
||||||
|
|
||||||
|
return await render_template("offlineshop/print.html", items=items)
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/confirmation")
|
||||||
|
async def confirmation_code():
|
||||||
|
style = "<style>* { font-size: 100px}</style>"
|
||||||
|
|
||||||
|
payment_hash = request.args.get("p")
|
||||||
|
payment: Payment = await get_standalone_payment(payment_hash)
|
||||||
|
if not payment:
|
||||||
|
return f"Couldn't find the payment {payment_hash}." + style, HTTPStatus.NOT_FOUND
|
||||||
|
if payment.pending:
|
||||||
|
return (
|
||||||
|
f"Payment {payment_hash} wasn't received yet. Please try again in a minute." + style,
|
||||||
|
HTTPStatus.PAYMENT_REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if payment.time + 60 * 15 < time.time():
|
||||||
|
return "too much time has passed." + style
|
||||||
|
|
||||||
|
item = await get_item(payment.extra.get("item"))
|
||||||
|
shop = await get_shop(item.shop)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"""
|
||||||
|
[{shop.get_code(payment_hash)}]<br>
|
||||||
|
{item.name}<br>
|
||||||
|
{item.price} {item.unit}<br>
|
||||||
|
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')}
|
||||||
|
"""
|
||||||
|
+ style
|
||||||
|
)
|
119
lnbits/extensions/offlineshop/views_api.py
Normal file
119
lnbits/extensions/offlineshop/views_api.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
from quart import g, jsonify
|
||||||
|
from http import HTTPStatus
|
||||||
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||||
|
|
||||||
|
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||||
|
from lnbits.utils.exchange_rates import currencies
|
||||||
|
|
||||||
|
from . import offlineshop_ext
|
||||||
|
from .crud import (
|
||||||
|
get_or_create_shop_by_wallet,
|
||||||
|
set_method,
|
||||||
|
add_item,
|
||||||
|
update_item,
|
||||||
|
get_items,
|
||||||
|
delete_item_from_shop,
|
||||||
|
)
|
||||||
|
from .models import ShopCounter
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/api/v1/currencies", methods=["GET"])
|
||||||
|
async def api_list_currencies_available():
|
||||||
|
return jsonify(list(currencies.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/api/v1/offlineshop", methods=["GET"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_shop_from_wallet():
|
||||||
|
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||||
|
items = await get_items(shop.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
**shop._asdict(),
|
||||||
|
**{
|
||||||
|
"otp_key": shop.otp_key,
|
||||||
|
"items": [item.values() for item in items],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
HTTPStatus.OK,
|
||||||
|
)
|
||||||
|
except LnurlInvalidUrl:
|
||||||
|
return (
|
||||||
|
jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}),
|
||||||
|
HTTPStatus.UPGRADE_REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/api/v1/offlineshop/items", methods=["POST"])
|
||||||
|
@offlineshop_ext.route("/api/v1/offlineshop/items/<item_id>", methods=["PUT"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
@api_validate_post_request(
|
||||||
|
schema={
|
||||||
|
"name": {"type": "string", "empty": False, "required": True},
|
||||||
|
"description": {"type": "string", "empty": False, "required": True},
|
||||||
|
"image": {"type": "string", "required": False, "nullable": True},
|
||||||
|
"price": {"type": "number", "required": True},
|
||||||
|
"unit": {"type": "string", "required": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def api_add_or_update_item(item_id=None):
|
||||||
|
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||||
|
if item_id == None:
|
||||||
|
await add_item(
|
||||||
|
shop.id,
|
||||||
|
g.data["name"],
|
||||||
|
g.data["description"],
|
||||||
|
g.data.get("image"),
|
||||||
|
g.data["price"],
|
||||||
|
g.data["unit"],
|
||||||
|
)
|
||||||
|
return "", HTTPStatus.CREATED
|
||||||
|
else:
|
||||||
|
await update_item(
|
||||||
|
shop.id,
|
||||||
|
item_id,
|
||||||
|
g.data["name"],
|
||||||
|
g.data["description"],
|
||||||
|
g.data.get("image"),
|
||||||
|
g.data["price"],
|
||||||
|
g.data["unit"],
|
||||||
|
)
|
||||||
|
return "", HTTPStatus.OK
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/api/v1/offlineshop/items/<item_id>", methods=["DELETE"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_delete_item(item_id):
|
||||||
|
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||||
|
await delete_item_from_shop(shop.id, item_id)
|
||||||
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
@offlineshop_ext.route("/api/v1/offlineshop/method", methods=["PUT"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
@api_validate_post_request(
|
||||||
|
schema={
|
||||||
|
"method": {"type": "string", "required": True, "nullable": False},
|
||||||
|
"wordlist": {"type": "string", "empty": True, "nullable": True, "required": False},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def api_set_method():
|
||||||
|
method = g.data["method"]
|
||||||
|
|
||||||
|
wordlist = g.data["wordlist"].split("\n") if g.data["wordlist"] else None
|
||||||
|
wordlist = [word.strip() for word in wordlist if word.strip()]
|
||||||
|
|
||||||
|
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||||
|
if not shop:
|
||||||
|
return "", HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
updated_shop = await set_method(shop.id, method, "\n".join(wordlist))
|
||||||
|
if not updated_shop:
|
||||||
|
return "", HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
ShopCounter.reset(updated_shop)
|
||||||
|
return "", HTTPStatus.OK
|
28
lnbits/extensions/offlineshop/wordlists.py
Normal file
28
lnbits/extensions/offlineshop/wordlists.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
animals = [
|
||||||
|
"albatross",
|
||||||
|
"bison",
|
||||||
|
"chicken",
|
||||||
|
"duck",
|
||||||
|
"eagle",
|
||||||
|
"flamingo",
|
||||||
|
"gorila",
|
||||||
|
"hamster",
|
||||||
|
"iguana",
|
||||||
|
"jaguar",
|
||||||
|
"koala",
|
||||||
|
"llama",
|
||||||
|
"macaroni penguim",
|
||||||
|
"numbat",
|
||||||
|
"octopus",
|
||||||
|
"platypus",
|
||||||
|
"quetzal",
|
||||||
|
"rabbit",
|
||||||
|
"salmon",
|
||||||
|
"tuna",
|
||||||
|
"unicorn",
|
||||||
|
"vulture",
|
||||||
|
"wolf",
|
||||||
|
"xenops",
|
||||||
|
"yak",
|
||||||
|
"zebra",
|
||||||
|
]
|
@ -17,8 +17,8 @@
|
|||||||
<code>[<paywall_object>, ...]</code>
|
<code>[<paywall_object>, ...]</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.url_root }}api/v1/paywalls -H
|
>curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{
|
||||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
g.user.wallets[0].inkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -48,11 +48,11 @@
|
|||||||
>
|
>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X POST {{ request.url_root }}api/v1/paywalls -d
|
>curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url":
|
||||||
'{"url": <string>, "memo": <string>, "description":
|
<string>, "memo": <string>, "description": <string>,
|
||||||
<string>, "amount": <integer>, "remembers":
|
"amount": <integer>, "remembers": <boolean>}' -H
|
||||||
<boolean>}' -H "Content-type: application/json" -H "X-Api-Key:
|
"Content-type: application/json" -H "X-Api-Key: {{
|
||||||
{{ g.user.wallets[0].adminkey }}"
|
g.user.wallets[0].adminkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
@ -42,8 +42,8 @@
|
|||||||
<code>JSON list of users</code>
|
<code>JSON list of users</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.url_root }}api/v1/users -H
|
>curl -X GET {{ request.url_root }}api/v1/users -H "X-Api-Key: {{
|
||||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
g.user.wallets[0].inkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -64,9 +64,8 @@
|
|||||||
<code>JSON wallet data</code>
|
<code>JSON wallet data</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.url_root
|
>curl -X GET {{ request.url_root }}api/v1/wallets/<user_id> -H
|
||||||
}}api/v1/wallets/<user_id> -H "X-Api-Key: {{
|
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||||
g.user.wallets[0].inkey }}"
|
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -87,9 +86,8 @@
|
|||||||
<code>JSON a wallets transactions</code>
|
<code>JSON a wallets transactions</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.url_root
|
>curl -X GET {{ request.url_root }}api/v1/wallets<wallet_id> -H
|
||||||
}}api/v1/wallets<wallet_id> -H "X-Api-Key: {{
|
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||||
g.user.wallets[0].inkey }}"
|
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -128,10 +126,10 @@
|
|||||||
>
|
>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X POST {{ request.url_root }}api/v1/users -d
|
>curl -X POST {{ request.url_root }}api/v1/users -d '{"admin_id": "{{
|
||||||
'{"admin_id": "{{ g.user.id }}", "wallet_name": <string>,
|
g.user.id }}", "wallet_name": <string>, "user_name":
|
||||||
"user_name": <string>}' -H "X-Api-Key: {{
|
<string>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
|
||||||
g.user.wallets[0].inkey }}" -H "Content-type: application/json"
|
"Content-type: application/json"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -165,10 +163,10 @@
|
|||||||
>
|
>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X POST {{ request.url_root }}api/v1/wallets -d
|
>curl -X POST {{ request.url_root }}api/v1/wallets -d '{"user_id":
|
||||||
'{"user_id": <string>, "wallet_name": <string>,
|
<string>, "wallet_name": <string>, "admin_id": "{{
|
||||||
"admin_id": "{{ g.user.id }}"}' -H "X-Api-Key: {{
|
g.user.id }}"}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
|
||||||
g.user.wallets[0].inkey }}" -H "Content-type: application/json"
|
"Content-type: application/json"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -189,9 +187,8 @@
|
|||||||
<code>{"X-Api-Key": <string>}</code>
|
<code>{"X-Api-Key": <string>}</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X DELETE {{ request.url_root
|
>curl -X DELETE {{ request.url_root }}api/v1/users/<user_id> -H
|
||||||
}}api/v1/users/<user_id> -H "X-Api-Key: {{
|
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||||
g.user.wallets[0].inkey }}"
|
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -207,9 +204,8 @@
|
|||||||
<code>{"X-Api-Key": <string>}</code>
|
<code>{"X-Api-Key": <string>}</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X DELETE {{ request.url_root
|
>curl -X DELETE {{ request.url_root }}api/v1/wallets/<wallet_id>
|
||||||
}}api/v1/wallets/<wallet_id> -H "X-Api-Key: {{
|
-H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||||
g.user.wallets[0].inkey }}"
|
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -230,8 +226,8 @@
|
|||||||
<code>{"X-Api-Key": <string>}</code>
|
<code>{"X-Api-Key": <string>}</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X POST {{ request.url_root }}api/v1/extensions -d
|
>curl -X POST {{ request.url_root }}api/v1/extensions -d '{"userid":
|
||||||
'{"userid": <string>, "extension": <string>, "active":
|
<string>, "extension": <string>, "active":
|
||||||
<integer>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
|
<integer>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
|
||||||
"Content-type: application/json"
|
"Content-type: application/json"
|
||||||
</code>
|
</code>
|
||||||
|
@ -22,8 +22,8 @@
|
|||||||
<code>[<withdraw_link_object>, ...]</code>
|
<code>[<withdraw_link_object>, ...]</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.url_root }}api/v1/links -H
|
>curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{
|
||||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
g.user.wallets[0].inkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -49,9 +49,8 @@
|
|||||||
<code>{"lnurl": <string>}</code>
|
<code>{"lnurl": <string>}</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.url_root
|
>curl -X GET {{ request.url_root }}api/v1/links/<withdraw_id> -H
|
||||||
}}api/v1/links/<withdraw_id> -H "X-Api-Key: {{
|
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||||
g.user.wallets[0].inkey }}"
|
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@ -79,8 +78,8 @@
|
|||||||
<code>{"lnurl": <string>}</code>
|
<code>{"lnurl": <string>}</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X POST {{ request.url_root }}api/v1/links -d
|
>curl -X POST {{ request.url_root }}api/v1/links -d '{"title":
|
||||||
'{"title": <string>, "min_withdrawable": <integer>,
|
<string>, "min_withdrawable": <integer>,
|
||||||
"max_withdrawable": <integer>, "uses": <integer>,
|
"max_withdrawable": <integer>, "uses": <integer>,
|
||||||
"wait_time": <integer>, "is_unique": <boolean>}' -H
|
"wait_time": <integer>, "is_unique": <boolean>}' -H
|
||||||
"Content-type: application/json" -H "X-Api-Key: {{
|
"Content-type: application/json" -H "X-Api-Key: {{
|
||||||
@ -115,9 +114,8 @@
|
|||||||
<code>{"lnurl": <string>}</code>
|
<code>{"lnurl": <string>}</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X PUT {{ request.url_root
|
>curl -X PUT {{ request.url_root }}api/v1/links/<withdraw_id> -d
|
||||||
}}api/v1/links/<withdraw_id> -d '{"title":
|
'{"title": <string>, "min_withdrawable": <integer>,
|
||||||
<string>, "min_withdrawable": <integer>,
|
|
||||||
"max_withdrawable": <integer>, "uses": <integer>,
|
"max_withdrawable": <integer>, "uses": <integer>,
|
||||||
"wait_time": <integer>, "is_unique": <boolean>}' -H
|
"wait_time": <integer>, "is_unique": <boolean>}' -H
|
||||||
"Content-type: application/json" -H "X-Api-Key: {{
|
"Content-type: application/json" -H "X-Api-Key: {{
|
||||||
@ -145,9 +143,8 @@
|
|||||||
<code></code>
|
<code></code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X DELETE {{ request.url_root
|
>curl -X DELETE {{ request.url_root }}api/v1/links/<withdraw_id>
|
||||||
}}api/v1/links/<withdraw_id> -H "X-Api-Key: {{
|
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
|
||||||
g.user.wallets[0].adminkey }}"
|
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
0
lnbits/utils/__init__.py
Normal file
0
lnbits/utils/__init__.py
Normal file
262
lnbits/utils/exchange_rates.py
Normal file
262
lnbits/utils/exchange_rates.py
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import trio # type: ignore
|
||||||
|
import httpx
|
||||||
|
from typing import Callable, NamedTuple
|
||||||
|
|
||||||
|
currencies = {
|
||||||
|
"AED": "United Arab Emirates Dirham",
|
||||||
|
"AFN": "Afghan Afghani",
|
||||||
|
"ALL": "Albanian Lek",
|
||||||
|
"AMD": "Armenian Dram",
|
||||||
|
"ANG": "Netherlands Antillean Gulden",
|
||||||
|
"AOA": "Angolan Kwanza",
|
||||||
|
"ARS": "Argentine Peso",
|
||||||
|
"AUD": "Australian Dollar",
|
||||||
|
"AWG": "Aruban Florin",
|
||||||
|
"AZN": "Azerbaijani Manat",
|
||||||
|
"BAM": "Bosnia and Herzegovina Convertible Mark",
|
||||||
|
"BBD": "Barbadian Dollar",
|
||||||
|
"BDT": "Bangladeshi Taka",
|
||||||
|
"BGN": "Bulgarian Lev",
|
||||||
|
"BHD": "Bahraini Dinar",
|
||||||
|
"BIF": "Burundian Franc",
|
||||||
|
"BMD": "Bermudian Dollar",
|
||||||
|
"BND": "Brunei Dollar",
|
||||||
|
"BOB": "Bolivian Boliviano",
|
||||||
|
"BRL": "Brazilian Real",
|
||||||
|
"BSD": "Bahamian Dollar",
|
||||||
|
"BTN": "Bhutanese Ngultrum",
|
||||||
|
"BWP": "Botswana Pula",
|
||||||
|
"BYN": "Belarusian Ruble",
|
||||||
|
"BYR": "Belarusian Ruble",
|
||||||
|
"BZD": "Belize Dollar",
|
||||||
|
"CAD": "Canadian Dollar",
|
||||||
|
"CDF": "Congolese Franc",
|
||||||
|
"CHF": "Swiss Franc",
|
||||||
|
"CLF": "Unidad de Fomento",
|
||||||
|
"CLP": "Chilean Peso",
|
||||||
|
"CNH": "Chinese Renminbi Yuan Offshore",
|
||||||
|
"CNY": "Chinese Renminbi Yuan",
|
||||||
|
"COP": "Colombian Peso",
|
||||||
|
"CRC": "Costa Rican Colón",
|
||||||
|
"CUC": "Cuban Convertible Peso",
|
||||||
|
"CVE": "Cape Verdean Escudo",
|
||||||
|
"CZK": "Czech Koruna",
|
||||||
|
"DJF": "Djiboutian Franc",
|
||||||
|
"DKK": "Danish Krone",
|
||||||
|
"DOP": "Dominican Peso",
|
||||||
|
"DZD": "Algerian Dinar",
|
||||||
|
"EGP": "Egyptian Pound",
|
||||||
|
"ERN": "Eritrean Nakfa",
|
||||||
|
"ETB": "Ethiopian Birr",
|
||||||
|
"EUR": "Euro",
|
||||||
|
"FJD": "Fijian Dollar",
|
||||||
|
"FKP": "Falkland Pound",
|
||||||
|
"GBP": "British Pound",
|
||||||
|
"GEL": "Georgian Lari",
|
||||||
|
"GGP": "Guernsey Pound",
|
||||||
|
"GHS": "Ghanaian Cedi",
|
||||||
|
"GIP": "Gibraltar Pound",
|
||||||
|
"GMD": "Gambian Dalasi",
|
||||||
|
"GNF": "Guinean Franc",
|
||||||
|
"GTQ": "Guatemalan Quetzal",
|
||||||
|
"GYD": "Guyanese Dollar",
|
||||||
|
"HKD": "Hong Kong Dollar",
|
||||||
|
"HNL": "Honduran Lempira",
|
||||||
|
"HRK": "Croatian Kuna",
|
||||||
|
"HTG": "Haitian Gourde",
|
||||||
|
"HUF": "Hungarian Forint",
|
||||||
|
"IDR": "Indonesian Rupiah",
|
||||||
|
"ILS": "Israeli New Sheqel",
|
||||||
|
"IMP": "Isle of Man Pound",
|
||||||
|
"INR": "Indian Rupee",
|
||||||
|
"IQD": "Iraqi Dinar",
|
||||||
|
"ISK": "Icelandic Króna",
|
||||||
|
"JEP": "Jersey Pound",
|
||||||
|
"JMD": "Jamaican Dollar",
|
||||||
|
"JOD": "Jordanian Dinar",
|
||||||
|
"JPY": "Japanese Yen",
|
||||||
|
"KES": "Kenyan Shilling",
|
||||||
|
"KGS": "Kyrgyzstani Som",
|
||||||
|
"KHR": "Cambodian Riel",
|
||||||
|
"KMF": "Comorian Franc",
|
||||||
|
"KRW": "South Korean Won",
|
||||||
|
"KWD": "Kuwaiti Dinar",
|
||||||
|
"KYD": "Cayman Islands Dollar",
|
||||||
|
"KZT": "Kazakhstani Tenge",
|
||||||
|
"LAK": "Lao Kip",
|
||||||
|
"LBP": "Lebanese Pound",
|
||||||
|
"LKR": "Sri Lankan Rupee",
|
||||||
|
"LRD": "Liberian Dollar",
|
||||||
|
"LSL": "Lesotho Loti",
|
||||||
|
"LYD": "Libyan Dinar",
|
||||||
|
"MAD": "Moroccan Dirham",
|
||||||
|
"MDL": "Moldovan Leu",
|
||||||
|
"MGA": "Malagasy Ariary",
|
||||||
|
"MKD": "Macedonian Denar",
|
||||||
|
"MMK": "Myanmar Kyat",
|
||||||
|
"MNT": "Mongolian Tögrög",
|
||||||
|
"MOP": "Macanese Pataca",
|
||||||
|
"MRO": "Mauritanian Ouguiya",
|
||||||
|
"MUR": "Mauritian Rupee",
|
||||||
|
"MVR": "Maldivian Rufiyaa",
|
||||||
|
"MWK": "Malawian Kwacha",
|
||||||
|
"MXN": "Mexican Peso",
|
||||||
|
"MYR": "Malaysian Ringgit",
|
||||||
|
"MZN": "Mozambican Metical",
|
||||||
|
"NAD": "Namibian Dollar",
|
||||||
|
"NGN": "Nigerian Naira",
|
||||||
|
"NIO": "Nicaraguan Córdoba",
|
||||||
|
"NOK": "Norwegian Krone",
|
||||||
|
"NPR": "Nepalese Rupee",
|
||||||
|
"NZD": "New Zealand Dollar",
|
||||||
|
"OMR": "Omani Rial",
|
||||||
|
"PAB": "Panamanian Balboa",
|
||||||
|
"PEN": "Peruvian Sol",
|
||||||
|
"PGK": "Papua New Guinean Kina",
|
||||||
|
"PHP": "Philippine Peso",
|
||||||
|
"PKR": "Pakistani Rupee",
|
||||||
|
"PLN": "Polish Złoty",
|
||||||
|
"PYG": "Paraguayan Guaraní",
|
||||||
|
"QAR": "Qatari Riyal",
|
||||||
|
"RON": "Romanian Leu",
|
||||||
|
"RSD": "Serbian Dinar",
|
||||||
|
"RUB": "Russian Ruble",
|
||||||
|
"RWF": "Rwandan Franc",
|
||||||
|
"SAR": "Saudi Riyal",
|
||||||
|
"SBD": "Solomon Islands Dollar",
|
||||||
|
"SCR": "Seychellois Rupee",
|
||||||
|
"SEK": "Swedish Krona",
|
||||||
|
"SGD": "Singapore Dollar",
|
||||||
|
"SHP": "Saint Helenian Pound",
|
||||||
|
"SLL": "Sierra Leonean Leone",
|
||||||
|
"SOS": "Somali Shilling",
|
||||||
|
"SRD": "Surinamese Dollar",
|
||||||
|
"SSP": "South Sudanese Pound",
|
||||||
|
"STD": "São Tomé and Príncipe Dobra",
|
||||||
|
"SVC": "Salvadoran Colón",
|
||||||
|
"SZL": "Swazi Lilangeni",
|
||||||
|
"THB": "Thai Baht",
|
||||||
|
"TJS": "Tajikistani Somoni",
|
||||||
|
"TMT": "Turkmenistani Manat",
|
||||||
|
"TND": "Tunisian Dinar",
|
||||||
|
"TOP": "Tongan Paʻanga",
|
||||||
|
"TRY": "Turkish Lira",
|
||||||
|
"TTD": "Trinidad and Tobago Dollar",
|
||||||
|
"TWD": "New Taiwan Dollar",
|
||||||
|
"TZS": "Tanzanian Shilling",
|
||||||
|
"UAH": "Ukrainian Hryvnia",
|
||||||
|
"UGX": "Ugandan Shilling",
|
||||||
|
"USD": "US Dollar",
|
||||||
|
"UYU": "Uruguayan Peso",
|
||||||
|
"UZS": "Uzbekistan Som",
|
||||||
|
"VEF": "Venezuelan Bolívar",
|
||||||
|
"VES": "Venezuelan Bolívar Soberano",
|
||||||
|
"VND": "Vietnamese Đồng",
|
||||||
|
"VUV": "Vanuatu Vatu",
|
||||||
|
"WST": "Samoan Tala",
|
||||||
|
"XAF": "Central African Cfa Franc",
|
||||||
|
"XAG": "Silver (Troy Ounce)",
|
||||||
|
"XAU": "Gold (Troy Ounce)",
|
||||||
|
"XCD": "East Caribbean Dollar",
|
||||||
|
"XDR": "Special Drawing Rights",
|
||||||
|
"XOF": "West African Cfa Franc",
|
||||||
|
"XPD": "Palladium",
|
||||||
|
"XPF": "Cfp Franc",
|
||||||
|
"XPT": "Platinum",
|
||||||
|
"YER": "Yemeni Rial",
|
||||||
|
"ZAR": "South African Rand",
|
||||||
|
"ZMW": "Zambian Kwacha",
|
||||||
|
"ZWL": "Zimbabwean Dollar",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Provider(NamedTuple):
|
||||||
|
name: str
|
||||||
|
domain: str
|
||||||
|
api_url: str
|
||||||
|
getter: Callable
|
||||||
|
|
||||||
|
|
||||||
|
exchange_rate_providers = {
|
||||||
|
"bitfinex": Provider(
|
||||||
|
"Bitfinex",
|
||||||
|
"bitfinex.com",
|
||||||
|
"https://api.bitfinex.com/v1/pubticker/{from}{to}",
|
||||||
|
lambda data, replacements: data["last_price"],
|
||||||
|
),
|
||||||
|
"bitstamp": Provider(
|
||||||
|
"Bitstamp",
|
||||||
|
"bitstamp.net",
|
||||||
|
"https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
|
||||||
|
lambda data, replacements: data["last"],
|
||||||
|
),
|
||||||
|
"coinbase": Provider(
|
||||||
|
"Coinbase",
|
||||||
|
"coinbase.com",
|
||||||
|
"https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
|
||||||
|
lambda data, replacements: data["data"]["rates"][replacements["TO"]],
|
||||||
|
),
|
||||||
|
"coinmate": Provider(
|
||||||
|
"CoinMate",
|
||||||
|
"coinmate.io",
|
||||||
|
"https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
|
||||||
|
lambda data, replacements: data["data"]["last"],
|
||||||
|
),
|
||||||
|
"kraken": Provider(
|
||||||
|
"Kraken",
|
||||||
|
"kraken.com",
|
||||||
|
"https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
|
||||||
|
lambda data, replacements: data["result"]["XXBTZ" + replacements["TO"]]["c"][0],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def btc_price(currency: str) -> float:
|
||||||
|
replacements = {"FROM": "BTC", "from": "btc", "TO": currency.upper(), "to": currency.lower()}
|
||||||
|
rates = []
|
||||||
|
send_channel, receive_channel = trio.open_memory_channel(0)
|
||||||
|
|
||||||
|
async def controller(nursery):
|
||||||
|
failures = 0
|
||||||
|
while True:
|
||||||
|
rate = await receive_channel.receive()
|
||||||
|
if rate:
|
||||||
|
rates.append(rate)
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
if len(rates) >= 2 or len(rates) == 1 and failures >= 2:
|
||||||
|
nursery.cancel_scope.cancel()
|
||||||
|
break
|
||||||
|
if failures == len(exchange_rate_providers):
|
||||||
|
nursery.cancel_scope.cancel()
|
||||||
|
break
|
||||||
|
|
||||||
|
async def fetch_price(key: str, provider: Provider):
|
||||||
|
try:
|
||||||
|
url = provider.api_url.format(**replacements)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(url, timeout=0.5)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
rate = float(provider.getter(data, replacements))
|
||||||
|
await send_channel.send(rate)
|
||||||
|
except Exception:
|
||||||
|
await send_channel.send(None)
|
||||||
|
|
||||||
|
async with trio.open_nursery() as nursery:
|
||||||
|
nursery.start_soon(controller, nursery)
|
||||||
|
for key, provider in exchange_rate_providers.items():
|
||||||
|
nursery.start_soon(fetch_price, key, provider)
|
||||||
|
|
||||||
|
if not rates:
|
||||||
|
return 9999999999
|
||||||
|
|
||||||
|
return sum([rate for rate in rates]) / len(rates)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fiat_rate_satoshis(currency: str) -> float:
|
||||||
|
return int(100_000_000 / (await btc_price(currency)))
|
||||||
|
|
||||||
|
|
||||||
|
async def fiat_amount_as_satoshis(amount: float, currency: str) -> int:
|
||||||
|
return int(amount * (await get_fiat_rate_satoshis(currency)))
|
Reference in New Issue
Block a user