From 732d06c1e515417d7d1345adb7eeeb9826c537fe Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 7 Mar 2021 00:08:36 -0300 Subject: [PATCH 01/14] basic offlineshop functionality. --- lnbits/core/crud.py | 13 +- lnbits/extensions/offlineshop/README.md | 1 + lnbits/extensions/offlineshop/__init__.py | 12 ++ lnbits/extensions/offlineshop/config.json | 8 + lnbits/extensions/offlineshop/crud.py | 80 +++++++ lnbits/extensions/offlineshop/helpers.py | 48 +++++ lnbits/extensions/offlineshop/lnurl.py | 63 ++++++ lnbits/extensions/offlineshop/migrations.py | 28 +++ lnbits/extensions/offlineshop/models.py | 65 ++++++ .../extensions/offlineshop/static/js/index.js | 157 ++++++++++++++ .../templates/offlineshop/_api_docs.html | 46 +++++ .../templates/offlineshop/index.html | 195 ++++++++++++++++++ lnbits/extensions/offlineshop/views.py | 34 +++ lnbits/extensions/offlineshop/views_api.py | 72 +++++++ lnbits/extensions/offlineshop/wordlists.py | 28 +++ 15 files changed, 842 insertions(+), 8 deletions(-) create mode 100644 lnbits/extensions/offlineshop/README.md create mode 100644 lnbits/extensions/offlineshop/__init__.py create mode 100644 lnbits/extensions/offlineshop/config.json create mode 100644 lnbits/extensions/offlineshop/crud.py create mode 100644 lnbits/extensions/offlineshop/helpers.py create mode 100644 lnbits/extensions/offlineshop/lnurl.py create mode 100644 lnbits/extensions/offlineshop/migrations.py create mode 100644 lnbits/extensions/offlineshop/models.py create mode 100644 lnbits/extensions/offlineshop/static/js/index.js create mode 100644 lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html create mode 100644 lnbits/extensions/offlineshop/templates/offlineshop/index.html create mode 100644 lnbits/extensions/offlineshop/views.py create mode 100644 lnbits/extensions/offlineshop/views_api.py create mode 100644 lnbits/extensions/offlineshop/wordlists.py diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 5b0d572c5..f9321056c 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -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( """ SELECT * FROM apipayments - WHERE checking_id = ? + WHERE checking_id = ? OR payment_hash = ? + LIMIT 1 """, - (checking_id,), + (checking_id_or_hash, checking_id_or_hash), ) return Payment.from_row(row) if row else None @@ -285,11 +286,7 @@ async def create_payment( async def update_payment_status(checking_id: str, pending: bool) -> None: await db.execute( - "UPDATE apipayments SET pending = ? WHERE checking_id = ?", - ( - int(pending), - checking_id, - ), + "UPDATE apipayments SET pending = ? WHERE checking_id = ?", (int(pending), checking_id,), ) diff --git a/lnbits/extensions/offlineshop/README.md b/lnbits/extensions/offlineshop/README.md new file mode 100644 index 000000000..254bc6884 --- /dev/null +++ b/lnbits/extensions/offlineshop/README.md @@ -0,0 +1 @@ +# Offline Shop diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py new file mode 100644 index 000000000..e24fa14b5 --- /dev/null +++ b/lnbits/extensions/offlineshop/__init__.py @@ -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 diff --git a/lnbits/extensions/offlineshop/config.json b/lnbits/extensions/offlineshop/config.json new file mode 100644 index 000000000..3748c9f45 --- /dev/null +++ b/lnbits/extensions/offlineshop/config.json @@ -0,0 +1,8 @@ +{ + "name": "Offline Shop", + "short_description": "Sell stuff with Lightning and lnurlpay on a shop without internet or any electronic device.", + "icon": "nature_people", + "contributors": [ + "fiatjaf" + ] +} diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py new file mode 100644 index 000000000..0544edeaa --- /dev/null +++ b/lnbits/extensions/offlineshop/crud.py @@ -0,0 +1,80 @@ +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 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), + ) diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py new file mode 100644 index 000000000..13e748574 --- /dev/null +++ b/lnbits/extensions/offlineshop/helpers.py @@ -0,0 +1,48 @@ +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) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py new file mode 100644 index 000000000..780593ced --- /dev/null +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -0,0 +1,63 @@ +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 . import offlineshop_ext +from .crud import get_shop, get_item +from .helpers import get_fiat_rate + + +@offlineshop_ext.route("/lnurl/", 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."}) + + rate = await get_fiat_rate(item.unit) if item.unit != "sat" else 1 + price_msat = item.price * 1000 * rate + + resp = LnurlPayResponse( + callback=url_for("shop.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/", 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: + rate = await get_fiat_rate(item.unit) + # allow some fluctuation (the fiat price may have changed between the calls) + min = rate * 995 * item.price + max = rate * 1010 * item.price + + 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=await 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(payment_hash, shop), routes=[]) + + return jsonify(resp.dict()) diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py new file mode 100644 index 000000000..72e9e501d --- /dev/null +++ b/lnbits/extensions/offlineshop/migrations.py @@ -0,0 +1,28 @@ +async def m001_initial(db): + """ + Initial offlineshop tables. + """ + await db.execute( + """ + CREATE TABLE shops ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet 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' + ); + """ + ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py new file mode 100644 index 000000000..5a0dcee11 --- /dev/null +++ b/lnbits/extensions/offlineshop/models.py @@ -0,0 +1,65 @@ +import json +from collections import OrderedDict +from quart import url_for +from typing import NamedTuple, Optional +from lnurl import encode as lnurl_encode # type: ignore +from lnurl.types import LnurlPayMetadata # type: ignore +from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore + + +class Shop(NamedTuple): + id: int + wallet: str + wordlist: str + + def get_word(self, payment_hash): + # initialize confirmation words cache + self.fulfilled_payments = self.words or OrderedDict() + + if payment_hash in self.fulfilled_payments: + return self.fulfilled_payments[payment_hash] + + # get a new word + self.counter = (self.counter or -1) + 1 + wordlist = self.wordlist.split("\n") + word = [self.counter % len(wordlist)] + + # cleanup confirmation words cache + to_remove = self.fulfilled_payments - 23 + if to_remove > 0: + for i in range(to_remove): + self.fulfilled_payments.popitem(False) + + return word + + +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)) + + async def lnurlpay_metadata(self) -> LnurlPayMetadata: + metadata = [["text/plain", self.description]] + + if self.image: + metadata.append(self.image.split(",")) + + return LnurlPayMetadata(json.dumps(metadata)) + + def success_action(self, shop: Shop, payment_hash: str) -> Optional[LnurlPaySuccessAction]: + if not self.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.", + ) diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js new file mode 100644 index 000000000..7cbdf3052 --- /dev/null +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -0,0 +1,157 @@ +/* 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, + offlineshop: { + wordlist: [], + items: [] + }, + itemDialog: { + show: false, + data: {...defaultItemData}, + units: ['sat', 'USD'] + } + } + }, + methods: { + async imageAdded(file) { + let image = new Image() + image.src = URL.createObjectURL(file) + let canvas = document.getElementById('uploading-image') + image.onload = async () => { + canvas.setAttribute('width', 300) + canvas.setAttribute('height', 300) + await pica.resize(image, canvas) + this.itemDialog.data.image = canvas.toDataURL() + } + }, + imageCleared() { + this.itemDialog.data.image = null + let canvas = document.getElementById('uploading-image') + canvas.setAttribute('height', 0) + canvas.setAttribute('width', 0) + let ctx = canvas.getContext('2d') + ctx.clearRect(0, 0, canvas.width, canvas.height) + }, + 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 + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + addItem() { + let {name, image, description, price, unit} = this.itemDialog.data + + LNbits.api + .request( + 'POST', + '/offlineshop/api/v1/offlineshop/items', + this.selectedWallet.inkey, + { + name, + description, + image, + price, + unit + } + ) + .then(response => { + this.$q.notify({ + message: `Item '${this.itemDialog.data.name}' added.`, + timeout: 700 + }) + this.loadShop() + this.itemsDialog.show = false + this.itemsDialog.data = {...defaultItemData} + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + 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() + } +}) diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html new file mode 100644 index 000000000..944c746a0 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -0,0 +1,46 @@ + + + +

+ Sell stuff offline, accept Lightning payments. Powered by LNURL-pay. +

+
+
+
+ + + + + + GET +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<offlineshop object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+
diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html new file mode 100644 index 000000000..3f75f8d28 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -0,0 +1,195 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+
+
Items
+
+
+ Add new item +
+
+ {% raw %} + + + + + {% endraw %} +
+
+ + + + + + + + + +
+ +
+ + +
LNbits OfflineShop extension
+
+ + + {% include "offlineshop/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + +
+
+ Add item +
+
+ Cancel +
+
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py new file mode 100644 index 000000000..140c1a766 --- /dev/null +++ b/lnbits/extensions/offlineshop/views.py @@ -0,0 +1,34 @@ +import time +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("/confirmation") +async def confirmation_code(): + 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}.", HTTPStatus.NOT_FOUND + if payment.pending: + return f"Payment {payment_hash} wasn't received yet. Please try again in a minute.", HTTPStatus.PAYMENT_REQUIRED + + if payment.time + 60 * 15 < time.time(): + return "too much time has passed." + + item = await get_item(payment.extra.get("item")) + shop = await get_shop(item.shop) + return shop.next_word(payment_hash) diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py new file mode 100644 index 000000000..749eec427 --- /dev/null +++ b/lnbits/extensions/offlineshop/views_api.py @@ -0,0 +1,72 @@ +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 . import offlineshop_ext +from .crud import ( + get_or_create_shop_by_wallet, + add_item, + update_item, + get_items, + delete_item_from_shop, +) + + +@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(), **{"items": [item._asdict() 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/", 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}, + "price": {"type": "number", "required": True}, + "unit": {"type": "string", "allowed": ["sat", "USD"], "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/", 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 diff --git a/lnbits/extensions/offlineshop/wordlists.py b/lnbits/extensions/offlineshop/wordlists.py new file mode 100644 index 000000000..ee3663e34 --- /dev/null +++ b/lnbits/extensions/offlineshop/wordlists.py @@ -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", +] From cda0819f64359ee620419e72c59f5e8bb48a9550 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 7 Mar 2021 14:37:31 -0300 Subject: [PATCH 02/14] improve and finish basic UI. --- .../lnurlp/templates/lnurlp/index.html | 2 +- lnbits/extensions/offlineshop/lnurl.py | 3 + .../extensions/offlineshop/static/js/index.js | 79 +++++++++++-------- .../templates/offlineshop/index.html | 36 +++++---- 4 files changed, 74 insertions(+), 46 deletions(-) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html index 79c44a508..c235dc7f3 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -133,7 +133,7 @@ - + item.id === itemId) + this.itemDialog.data = item + }, + imageAdded(file) { + let blobURL = URL.createObjectURL(file) let image = new Image() - image.src = URL.createObjectURL(file) - let canvas = document.getElementById('uploading-image') + image.src = blobURL image.onload = async () => { + let canvas = document.createElement('canvas') canvas.setAttribute('width', 300) canvas.setAttribute('height', 300) await pica.resize(image, canvas) this.itemDialog.data.image = canvas.toDataURL() + this.itemDialog = {...this.itemDialog} } }, imageCleared() { this.itemDialog.data.image = null - let canvas = document.getElementById('uploading-image') - canvas.setAttribute('height', 0) - canvas.setAttribute('width', 0) - let ctx = canvas.getContext('2d') - ctx.clearRect(0, 0, canvas.width, canvas.height) + this.itemDialog = {...this.itemDialog} }, disabledAddItemButton() { return ( @@ -73,34 +80,44 @@ new Vue({ LNbits.utils.notifyApiError(err) }) }, - addItem() { - let {name, image, description, price, unit} = this.itemDialog.data + async sendItem() { + let {id, name, image, description, price, unit} = this.itemDialog.data + const data = { + name, + description, + image, + price, + unit + } - LNbits.api - .request( - 'POST', - '/offlineshop/api/v1/offlineshop/items', - this.selectedWallet.inkey, - { - name, - description, - image, - price, - unit - } - ) - .then(response => { + 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 }) - this.loadShop() - this.itemsDialog.show = false - this.itemsDialog.data = {...defaultItemData} - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) + } + } 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) diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html index 3f75f8d28..8522dcff4 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/index.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -9,10 +9,7 @@
Items
- Add new item
@@ -51,20 +48,27 @@ target="_blank" > - {{ props.row.name }} + {{ props.row.name }} {{ props.row.description }} - - + {{ props.row.price }} {{ props.row.unit }} + - + - + + - Add item + {% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %} + Item +
Date: Sun, 7 Mar 2021 16:13:20 -0300 Subject: [PATCH 03/14] QR codes, printing, success-action and other fixes. --- lnbits/core/crud.py | 2 +- lnbits/extensions/offlineshop/config.json | 2 +- lnbits/extensions/offlineshop/lnurl.py | 6 +- lnbits/extensions/offlineshop/models.py | 76 +++++++++++++------ .../extensions/offlineshop/static/js/index.js | 5 ++ .../templates/offlineshop/index.html | 35 +++++++++ .../templates/offlineshop/print.html | 25 ++++++ lnbits/extensions/offlineshop/views.py | 25 +++++- lnbits/extensions/offlineshop/views_api.py | 4 +- 9 files changed, 147 insertions(+), 33 deletions(-) create mode 100644 lnbits/extensions/offlineshop/templates/offlineshop/print.html diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index f9321056c..2c0c5c3d5 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -136,7 +136,7 @@ async def get_standalone_payment(checking_id_or_hash: str) -> Optional[Payment]: """ SELECT * FROM apipayments - WHERE checking_id = ? OR payment_hash = ? + WHERE checking_id = ? OR hash = ? LIMIT 1 """, (checking_id_or_hash, checking_id_or_hash), diff --git a/lnbits/extensions/offlineshop/config.json b/lnbits/extensions/offlineshop/config.json index 3748c9f45..507b1d146 100644 --- a/lnbits/extensions/offlineshop/config.json +++ b/lnbits/extensions/offlineshop/config.json @@ -1,5 +1,5 @@ { - "name": "Offline Shop", + "name": "OfflineShop", "short_description": "Sell stuff with Lightning and lnurlpay on a shop without internet or any electronic device.", "icon": "nature_people", "contributors": [ diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index 171ff970b..e53a145cd 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -22,7 +22,7 @@ async def lnurl_response(item_id): price_msat = item.price * 1000 * rate resp = LnurlPayResponse( - callback=url_for("shop.lnurl_callback", item_id=item.id, _external=True), + 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(), @@ -56,11 +56,11 @@ async def lnurl_callback(item_id): payment_hash, payment_request = await create_invoice( wallet_id=shop.wallet, amount=int(amount_received / 1000), - memo=await item.name, + 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(payment_hash, shop), routes=[]) + resp = LnurlPayActionResponse(pr=payment_request, success_action=item.success_action(shop, payment_hash), routes=[]) return jsonify(resp.dict()) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py index 5a0dcee11..490b8a6a9 100644 --- a/lnbits/extensions/offlineshop/models.py +++ b/lnbits/extensions/offlineshop/models.py @@ -1,11 +1,54 @@ import json from collections import OrderedDict from quart import url_for -from typing import NamedTuple, Optional +from typing import NamedTuple, Optional, List from lnurl import encode as lnurl_encode # type: ignore from lnurl.types import LnurlPayMetadata # type: ignore from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore +shop_counters = {} + + +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 @@ -13,24 +56,8 @@ class Shop(NamedTuple): wordlist: str def get_word(self, payment_hash): - # initialize confirmation words cache - self.fulfilled_payments = self.words or OrderedDict() - - if payment_hash in self.fulfilled_payments: - return self.fulfilled_payments[payment_hash] - - # get a new word - self.counter = (self.counter or -1) + 1 - wordlist = self.wordlist.split("\n") - word = [self.counter % len(wordlist)] - - # cleanup confirmation words cache - to_remove = self.fulfilled_payments - 23 - if to_remove > 0: - for i in range(to_remove): - self.fulfilled_payments.popitem(False) - - return word + sc = ShopCounter.invoke(self) + return sc.get_word(payment_hash) class Item(NamedTuple): @@ -45,18 +72,23 @@ class Item(NamedTuple): @property def lnurl(self) -> str: - return lnurl_encode(url_for("offlineshop.lnurl_response", item_id=self.id)) + 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(",")) + 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 self.wordlist: + if not shop.wordlist: return None return UrlAction( diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index f0c1445bc..a55cf4368 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -25,6 +25,11 @@ new Vue({ } } }, + computed: { + printItems() { + return this.offlineshop.items.filter(({enabled}) => enabled) + } + }, methods: { openNewDialog() { this.itemDialog.show = true diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html index 8522dcff4..6ede54544 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/index.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -18,6 +18,7 @@ + +
+ Print QR Codes +
@@ -120,6 +131,30 @@ +
+
Adding a new item
+ + + + + +
+ Copy LNURL +
+
+
{{ item.name }}
+ +
{{ item.price }}
+
+ +{% endraw %} {% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py index 140c1a766..ace818b00 100644 --- a/lnbits/extensions/offlineshop/views.py +++ b/lnbits/extensions/offlineshop/views.py @@ -17,18 +17,35 @@ 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 = "" + 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}.", HTTPStatus.NOT_FOUND + 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.", HTTPStatus.PAYMENT_REQUIRED + 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." + return "too much time has passed." + style item = await get_item(payment.extra.get("item")) shop = await get_shop(item.shop) - return shop.next_word(payment_hash) + + return shop.get_word(payment_hash) + style diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index 749eec427..430b23f0e 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -22,7 +22,7 @@ async def api_shop_from_wallet(): try: return ( - jsonify({**shop._asdict(), **{"items": [item._asdict() for item in items],},}), + jsonify({**shop._asdict(), **{"items": [item.values() for item in items],},}), HTTPStatus.OK, ) except LnurlInvalidUrl: @@ -39,7 +39,7 @@ async def api_shop_from_wallet(): schema={ "name": {"type": "string", "empty": False, "required": True}, "description": {"type": "string", "empty": False, "required": True}, - "image": {"type": "string", "required": False}, + "image": {"type": "string", "required": False, "nullable": True}, "price": {"type": "number", "required": True}, "unit": {"type": "string", "allowed": ["sat", "USD"], "required": True}, } From 773103f8938adf041c1816b3011903794b629fa8 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 7 Mar 2021 16:38:23 -0300 Subject: [PATCH 04/14] instructions and API docs. --- .../templates/offlineshop/_api_docs.html | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html index 944c746a0..f0d4a151a 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -6,8 +6,21 @@ > +
    +
  1. Register items.
  2. +
  3. Print QR codes and paste them on your store, your menu, somewhere, somehow.
  4. +
  5. Clients scan the QR codes and get information about the items plus the price on their phones directly (they must have internet)
  6. +
  7. Once they decide to pay, they'll get an invoice on their phones automatically
  8. +
  9. When the payment is confirmed, a confirmation code will be issued for them.
  10. +

- Sell stuff offline, accept Lightning payments. Powered by LNURL-pay. + 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. +

+

+ For example, if your wordlist is [apple, banana, coconut] the first purchase will be apple, the second banana and so on. When it gets to the end it starts from the beginning again. +

+

+ Powered by LNURL-pay.

@@ -19,6 +32,31 @@ label="API info" :content-inset-level="0.5" > + + + + POST +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 201 OK
+
Curl example
+ 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">}' + +
+
+
Returns 200 OK (application/json) - [<offlineshop object>, ...] + {"id": <integer>, "wallet": <string>, "wordlist": + <string>, "items": [{"id": <integer>, "name": <string>, "description": <string>, "image": <string>, "enabled": <boolean>, "price": <integer>, "unit": <string>, "lnurl": <string>}, ...]}</code + >
Curl example
curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H @@ -43,4 +84,46 @@
+ + + + PUT +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 200 OK
+
Curl example
+ 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">}' + +
+
+
+ + + + DELETE +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 200 OK
+
Curl example
+ curl -X GET {{ request.url_root + }}/offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key: + {{ g.user.wallets[0].inkey }}" + +
+
+
From c7717a611a06ab357982f32627a06248e92c2b15 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 7 Mar 2021 17:03:01 -0300 Subject: [PATCH 05/14] UI to change the wordlist. --- lnbits/extensions/offlineshop/crud.py | 7 +++++ .../extensions/offlineshop/static/js/index.js | 19 +++++++++++++ .../templates/offlineshop/index.html | 27 +++++++++++++++++++ lnbits/extensions/offlineshop/views_api.py | 23 ++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py index 0544edeaa..2d9376dd0 100644 --- a/lnbits/extensions/offlineshop/crud.py +++ b/lnbits/extensions/offlineshop/crud.py @@ -32,6 +32,13 @@ async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]: return Shop(**dict(row)) if row else None +async def set_wordlist(shop: int, wordlist: str) -> Optional[Shop]: + await db.execute( + "UPDATE shops SET wordlist = ? WHERE id = ?", (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( """ diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index a55cf4368..84b6f8650 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -85,6 +85,25 @@ new Vue({ LNbits.utils.notifyApiError(err) }) }, + async updateWordlist() { + try { + await LNbits.api.request( + 'PUT', + '/offlineshop/api/v1/offlineshop/wordlist', + this.selectedWallet.inkey, + {wordlist: this.offlineshop.wordlist} + ) + this.$q.notify({ + message: `Wordlist updated. Counter reset.`, + timeout: 700 + }) + } catch (err) { + LNbits.utils.notifyApiError(err) + return + } + + this.loadShop() + }, async sendItem() { let {id, name, image, description, price, unit} = this.itemDialog.data const data = { diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html index 6ede54544..8de5a8ede 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/index.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -90,6 +90,10 @@ +
+
Wallet Shop
+
+
+ + + +
+
Wordlist
+
+ +
+
+ +
+
+ + Update Wordlist + + Reset +
+
+
+
+
diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index 430b23f0e..41aa2cf6f 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -7,11 +7,13 @@ from lnbits.decorators import api_check_wallet_key, api_validate_post_request from . import offlineshop_ext from .crud import ( get_or_create_shop_by_wallet, + set_wordlist, add_item, update_item, get_items, delete_item_from_shop, ) +from .models import ShopCounter @offlineshop_ext.route("/api/v1/offlineshop", methods=["GET"]) @@ -70,3 +72,24 @@ 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/wordlist", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={"wordlist": {"type": "string", "empty": True, "nullable": True, "required": True},} +) +async def api_set_wordlist(): + 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_wordlist(shop.id, "\n".join(wordlist)) + if not updated_shop: + return "", HTTPStatus.NOT_FOUND + + ShopCounter.reset(updated_shop) + return "", HTTPStatus.OK From 1630a28da0aa8d79c4936c9f3b67bb73b31d7287 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 7 Mar 2021 19:18:02 -0300 Subject: [PATCH 06/14] prettier and black stuff. --- lnbits/core/crud.py | 6 ++- lnbits/extensions/offlineshop/crud.py | 20 +++++++-- .../templates/offlineshop/_api_docs.html | 42 +++++++++++++------ lnbits/extensions/offlineshop/views_api.py | 20 +++++++-- 4 files changed, 69 insertions(+), 19 deletions(-) diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 2c0c5c3d5..2c91bc22a 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -286,7 +286,11 @@ async def create_payment( async def update_payment_status(checking_id: str, pending: bool) -> None: await db.execute( - "UPDATE apipayments SET pending = ? WHERE checking_id = ?", (int(pending), checking_id,), + "UPDATE apipayments SET pending = ? WHERE checking_id = ?", + ( + int(pending), + checking_id, + ), ) diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py index 2d9376dd0..5da350a5e 100644 --- a/lnbits/extensions/offlineshop/crud.py +++ b/lnbits/extensions/offlineshop/crud.py @@ -34,12 +34,20 @@ async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]: async def set_wordlist(shop: int, wordlist: str) -> Optional[Shop]: await db.execute( - "UPDATE shops SET wordlist = ? WHERE id = ?", (wordlist, shop), + "UPDATE shops SET wordlist = ? WHERE id = ?", + (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: +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) @@ -51,7 +59,13 @@ async def add_item(shop: int, name: str, description: str, image: Optional[str], async def update_item( - shop: int, item_id: int, name: str, description: str, image: Optional[str], price: int, unit: str, + shop: int, + item_id: int, + name: str, + description: str, + image: Optional[str], + price: int, + unit: str, ) -> int: await db.execute( """ diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html index f0d4a151a..1e3bf0519 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -8,20 +8,35 @@
  1. Register items.
  2. -
  3. Print QR codes and paste them on your store, your menu, somewhere, somehow.
  4. -
  5. Clients scan the QR codes and get information about the items plus the price on their phones directly (they must have internet)
  6. -
  7. Once they decide to pay, they'll get an invoice on their phones automatically
  8. -
  9. When the payment is confirmed, a confirmation code will be issued for them.
  10. +
  11. + Print QR codes and paste them on your store, your menu, somewhere, + somehow. +
  12. +
  13. + Clients scan the QR codes and get information about the items plus the + price on their phones directly (they must have internet) +
  14. +
  15. + Once they decide to pay, they'll get an invoice on their phones + automatically +
  16. +
  17. + When the payment is confirmed, a confirmation code will be issued for + them. +

- 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. + 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.

- For example, if your wordlist is [apple, banana, coconut] the first purchase will be apple, the second banana and so on. When it gets to the end it starts from the beginning again. -

-

- Powered by LNURL-pay. + For example, if your wordlist is + [apple, banana, coconut] the first purchase will be + apple, the second banana and so on. When it + gets to the end it starts from the beginning again.

+

Powered by LNURL-pay.

@@ -51,8 +66,8 @@ }}/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">}' + <data-uri string>, "price": <integer>, "unit": <"sat" + or "USD">}'
@@ -74,7 +89,10 @@ {"id": <integer>, "wallet": <string>, "wordlist": - <string>, "items": [{"id": <integer>, "name": <string>, "description": <string>, "image": <string>, "enabled": <boolean>, "price": <integer>, "unit": <string>, "lnurl": <string>}, ...]}</code + <string>, "items": [{"id": <integer>, "name": + <string>, "description": <string>, "image": + <string>, "enabled": <boolean>, "price": <integer>, + "unit": <string>, "lnurl": <string>}, ...]}<
Curl example
Date: Sun, 7 Mar 2021 19:20:39 -0300 Subject: [PATCH 07/14] prettier changes its rules everyday. --- .../paywall/templates/paywall/_api_docs.html | 14 +++--- .../templates/usermanager/_api_docs.html | 44 +++++++++---------- .../templates/withdraw/_api_docs.html | 23 +++++----- 3 files changed, 37 insertions(+), 44 deletions(-) diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html index 3884c3b52..1157fa467 100644 --- a/lnbits/extensions/paywall/templates/paywall/_api_docs.html +++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html @@ -17,8 +17,8 @@ [<paywall_object>, ...]
Curl example
curl -X GET {{ request.url_root }}api/v1/paywalls -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" @@ -48,11 +48,11 @@ >
Curl 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: - {{ g.user.wallets[0].adminkey }}" + >curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url": + <string>, "memo": <string>, "description": <string>, + "amount": <integer>, "remembers": <boolean>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html index 7b1925a50..fbd13e725 100644 --- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html +++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html @@ -42,8 +42,8 @@ JSON list of users
Curl example
curl -X GET {{ request.url_root }}api/v1/users -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/users -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" @@ -64,9 +64,8 @@ JSON wallet data
Curl example
curl -X GET {{ request.url_root - }}api/v1/wallets/<user_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/wallets/<user_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" @@ -87,9 +86,8 @@ JSON a wallets transactions
Curl example
curl -X GET {{ request.url_root - }}api/v1/wallets<wallet_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/wallets<wallet_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" @@ -128,10 +126,10 @@ >
Curl example
curl -X POST {{ request.url_root }}api/v1/users -d - '{"admin_id": "{{ g.user.id }}", "wallet_name": <string>, - "user_name": <string>}' -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" -H "Content-type: application/json" + >curl -X POST {{ request.url_root }}api/v1/users -d '{"admin_id": "{{ + g.user.id }}", "wallet_name": <string>, "user_name": + <string>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H + "Content-type: application/json" @@ -165,10 +163,10 @@ >
Curl example
curl -X POST {{ request.url_root }}api/v1/wallets -d - '{"user_id": <string>, "wallet_name": <string>, - "admin_id": "{{ g.user.id }}"}' -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" -H "Content-type: application/json" + >curl -X POST {{ request.url_root }}api/v1/wallets -d '{"user_id": + <string>, "wallet_name": <string>, "admin_id": "{{ + g.user.id }}"}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H + "Content-type: application/json" @@ -189,9 +187,8 @@ {"X-Api-Key": <string>}
Curl example
curl -X DELETE {{ request.url_root - }}api/v1/users/<user_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + >curl -X DELETE {{ request.url_root }}api/v1/users/<user_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" @@ -207,9 +204,8 @@ {"X-Api-Key": <string>}
Curl example
curl -X DELETE {{ request.url_root - }}api/v1/wallets/<wallet_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + >curl -X DELETE {{ request.url_root }}api/v1/wallets/<wallet_id> + -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" @@ -230,8 +226,8 @@ {"X-Api-Key": <string>}
Curl example
curl -X POST {{ request.url_root }}api/v1/extensions -d - '{"userid": <string>, "extension": <string>, "active": + >curl -X POST {{ request.url_root }}api/v1/extensions -d '{"userid": + <string>, "extension": <string>, "active": <integer>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H "Content-type: application/json" diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html index bc1aac2b2..18a0a5420 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html +++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html @@ -22,8 +22,8 @@ [<withdraw_link_object>, ...]
Curl example
curl -X GET {{ request.url_root }}api/v1/links -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" @@ -49,9 +49,8 @@ {"lnurl": <string>}
Curl example
curl -X GET {{ request.url_root - }}api/v1/links/<withdraw_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/links/<withdraw_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" @@ -79,8 +78,8 @@ {"lnurl": <string>}
Curl example
curl -X POST {{ request.url_root }}api/v1/links -d - '{"title": <string>, "min_withdrawable": <integer>, + >curl -X POST {{ request.url_root }}api/v1/links -d '{"title": + <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: {{ @@ -115,9 +114,8 @@ {"lnurl": <string>}
Curl example
curl -X PUT {{ request.url_root - }}api/v1/links/<withdraw_id> -d '{"title": - <string>, "min_withdrawable": <integer>, + >curl -X PUT {{ request.url_root }}api/v1/links/<withdraw_id> -d + '{"title": <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: {{ @@ -145,9 +143,8 @@
Curl example
curl -X DELETE {{ request.url_root - }}api/v1/links/<withdraw_id> -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" + >curl -X DELETE {{ request.url_root }}api/v1/links/<withdraw_id> + -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" From d9b9d1e9b2fb559b584458750fb5a212f4348f7f Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 14 Mar 2021 13:01:19 -0300 Subject: [PATCH 08/14] more info on confirmation code screen. --- lnbits/extensions/offlineshop/models.py | 4 ++-- lnbits/extensions/offlineshop/views.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py index 490b8a6a9..175b440b0 100644 --- a/lnbits/extensions/offlineshop/models.py +++ b/lnbits/extensions/offlineshop/models.py @@ -1,12 +1,12 @@ import json from collections import OrderedDict from quart import url_for -from typing import NamedTuple, Optional, List +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 -shop_counters = {} +shop_counters: Dict = {} class ShopCounter(object): diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py index ace818b00..86c9d9c0a 100644 --- a/lnbits/extensions/offlineshop/views.py +++ b/lnbits/extensions/offlineshop/views.py @@ -1,4 +1,5 @@ import time +from datetime import datetime from quart import g, render_template, request from http import HTTPStatus @@ -48,4 +49,12 @@ async def confirmation_code(): item = await get_item(payment.extra.get("item")) shop = await get_shop(item.shop) - return shop.get_word(payment_hash) + style + return ( + f""" +[{shop.get_word(payment_hash)}] +{item.name} +{item.price} {item.unit} +{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')} + """ + + style + ) From a653a5327be396d69fb92e0db798f7a0f4d0ea1b Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 14 Mar 2021 14:21:26 -0300 Subject: [PATCH 09/14] support totp confirmation method. --- lnbits/extensions/offlineshop/crud.py | 6 +- lnbits/extensions/offlineshop/helpers.py | 17 ++++ lnbits/extensions/offlineshop/lnurl.py | 6 +- lnbits/extensions/offlineshop/migrations.py | 1 + lnbits/extensions/offlineshop/models.py | 23 ++++- .../extensions/offlineshop/static/js/index.js | 21 +++-- .../templates/offlineshop/index.html | 83 +++++++++++++++++-- lnbits/extensions/offlineshop/views.py | 6 +- lnbits/extensions/offlineshop/views_api.py | 14 ++-- 9 files changed, 147 insertions(+), 30 deletions(-) diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py index 5da350a5e..365015a3e 100644 --- a/lnbits/extensions/offlineshop/crud.py +++ b/lnbits/extensions/offlineshop/crud.py @@ -32,10 +32,10 @@ async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]: return Shop(**dict(row)) if row else None -async def set_wordlist(shop: int, wordlist: str) -> Optional[Shop]: +async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]: await db.execute( - "UPDATE shops SET wordlist = ? WHERE id = ?", - (wordlist, shop), + "UPDATE shops SET method = ?, wordlist = ? WHERE id = ?", + (method, wordlist, shop), ) return await get_shop(shop) diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py index 13e748574..2930809dc 100644 --- a/lnbits/extensions/offlineshop/helpers.py +++ b/lnbits/extensions/offlineshop/helpers.py @@ -1,5 +1,9 @@ import trio # type: ignore import httpx +import base64 +import struct +import hmac +import time async def get_fiat_rate(currency: str): @@ -46,3 +50,16 @@ async def get_usd_rate(): satoshi_prices = [x for x in satoshi_prices if x] return sum(satoshi_prices) / len(satoshi_prices) + + +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) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index e53a145cd..f58a59ebe 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -61,6 +61,10 @@ async def lnurl_callback(item_id): extra={"tag": "offlineshop", "item": item.id}, ) - resp = LnurlPayActionResponse(pr=payment_request, success_action=item.success_action(shop, payment_hash), routes=[]) + resp = LnurlPayActionResponse( + pr=payment_request, + success_action=item.success_action(shop, payment_hash) if shop.method else None, + routes=[], + ) return jsonify(resp.dict()) diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py index 72e9e501d..8e8a4877b 100644 --- a/lnbits/extensions/offlineshop/migrations.py +++ b/lnbits/extensions/offlineshop/migrations.py @@ -7,6 +7,7 @@ async def m001_initial(db): CREATE TABLE shops ( id INTEGER PRIMARY KEY AUTOINCREMENT, wallet TEXT NOT NULL, + method TEXT NOT NULL, wordlist TEXT ); """ diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py index 175b440b0..52cf95f28 100644 --- a/lnbits/extensions/offlineshop/models.py +++ b/lnbits/extensions/offlineshop/models.py @@ -1,4 +1,6 @@ import json +import base64 +import hashlib from collections import OrderedDict from quart import url_for from typing import NamedTuple, Optional, List, Dict @@ -6,6 +8,8 @@ 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 = {} @@ -53,11 +57,24 @@ class ShopCounter(object): class Shop(NamedTuple): id: int wallet: str + method: str wordlist: str - def get_word(self, payment_hash): - sc = ShopCounter.invoke(self) - return sc.get_word(payment_hash) + @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): diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index 84b6f8650..f1d612f4e 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -14,7 +14,10 @@ new Vue({ data() { return { selectedWallet: null, + confirmationMethod: 'wordlist', + wordlistTainted: false, offlineshop: { + method: null, wordlist: [], items: [] }, @@ -80,28 +83,32 @@ new Vue({ ) .then(response => { this.offlineshop = response.data + this.confirmationMethod = response.data.method + this.wordlistTainted = false }) .catch(err => { LNbits.utils.notifyApiError(err) }) }, - async updateWordlist() { + async setMethod() { try { await LNbits.api.request( 'PUT', - '/offlineshop/api/v1/offlineshop/wordlist', + '/offlineshop/api/v1/offlineshop/method', this.selectedWallet.inkey, - {wordlist: this.offlineshop.wordlist} + {method: this.confirmationMethod, wordlist: this.offlineshop.wordlist} ) - this.$q.notify({ - message: `Wordlist updated. Counter reset.`, - timeout: 700 - }) } 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() { diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html index 8de5a8ede..6dfbc9933 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/index.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -120,17 +120,41 @@ - -
-
Wordlist
-
- + + + + + + + +
- +
-
- +
+ Update Wordlist
+ +
+
+
+ + + +
+
+ + Set TOTP + +
+
+
+ +
+

+ 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. +

+ + + Disable Confirmation Codes + +
diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py index 86c9d9c0a..aa4ac3a13 100644 --- a/lnbits/extensions/offlineshop/views.py +++ b/lnbits/extensions/offlineshop/views.py @@ -51,9 +51,9 @@ async def confirmation_code(): return ( f""" -[{shop.get_word(payment_hash)}] -{item.name} -{item.price} {item.unit} +[{shop.get_code(payment_hash)}]
+{item.name}
+{item.price} {item.unit}
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')} """ + style diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index 211306e54..fd60014a4 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -7,7 +7,7 @@ from lnbits.decorators import api_check_wallet_key, api_validate_post_request from . import offlineshop_ext from .crud import ( get_or_create_shop_by_wallet, - set_wordlist, + set_method, add_item, update_item, get_items, @@ -28,6 +28,7 @@ async def api_shop_from_wallet(): { **shop._asdict(), **{ + "otp_key": shop.otp_key, "items": [item.values() for item in items], }, } @@ -86,14 +87,17 @@ async def api_delete_item(item_id): return "", HTTPStatus.NO_CONTENT -@offlineshop_ext.route("/api/v1/offlineshop/wordlist", methods=["PUT"]) +@offlineshop_ext.route("/api/v1/offlineshop/method", methods=["PUT"]) @api_check_wallet_key("invoice") @api_validate_post_request( schema={ - "wordlist": {"type": "string", "empty": True, "nullable": True, "required": True}, + "method": {"type": "string", "required": True, "nullable": False}, + "wordlist": {"type": "string", "empty": True, "nullable": True, "required": False}, } ) -async def api_set_wordlist(): +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()] @@ -101,7 +105,7 @@ async def api_set_wordlist(): if not shop: return "", HTTPStatus.NOT_FOUND - updated_shop = await set_wordlist(shop.id, "\n".join(wordlist)) + updated_shop = await set_method(shop.id, method, "\n".join(wordlist)) if not updated_shop: return "", HTTPStatus.NOT_FOUND From d13ca2afdb622f939fce73242ea3147679b86d10 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 14 Mar 2021 14:34:47 -0300 Subject: [PATCH 10/14] round USD rate to satoshis. --- lnbits/extensions/offlineshop/lnurl.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index f58a59ebe..7abf070e1 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -19,7 +19,7 @@ async def lnurl_response(item_id): return jsonify({"status": "ERROR", "reason": "Item disabled."}) rate = await get_fiat_rate(item.unit) if item.unit != "sat" else 1 - price_msat = item.price * 1000 * rate + price_msat = int(item.price * rate) * 1000 resp = LnurlPayResponse( callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True), @@ -43,8 +43,8 @@ async def lnurl_callback(item_id): else: rate = await get_fiat_rate(item.unit) # allow some fluctuation (the fiat price may have changed between the calls) - min = rate * 995 * item.price - max = rate * 1010 * item.price + min = int(rate * item.price) * 995 + max = int(rate * item.price) * 1010 amount_received = int(request.args.get("amount")) if amount_received < min: From 5142f1e29f8175eb8274aa7ef3fe16828fc4bf2f Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 14 Mar 2021 16:47:01 -0300 Subject: [PATCH 11/14] reduce image quality even more. --- lnbits/extensions/offlineshop/static/js/index.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index f1d612f4e..f7dbba9bc 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -49,9 +49,15 @@ new Vue({ image.src = blobURL image.onload = async () => { let canvas = document.createElement('canvas') - canvas.setAttribute('width', 300) - canvas.setAttribute('height', 300) - await pica.resize(image, 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} } From adc3e625739f8a9a20a355ed500aa84606ec48ee Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 14 Mar 2021 20:43:39 -0300 Subject: [PATCH 12/14] abstract exchange rates code into a "util". --- lnbits/extensions/lnurlp/helpers.py | 48 ----- lnbits/extensions/lnurlp/lnurl.py | 6 +- lnbits/extensions/lnurlp/views_api.py | 4 +- lnbits/extensions/offlineshop/helpers.py | 48 ----- lnbits/extensions/offlineshop/lnurl.py | 11 +- lnbits/utils/__init__.py | 0 lnbits/utils/exchange_rates.py | 262 +++++++++++++++++++++++ 7 files changed, 272 insertions(+), 107 deletions(-) delete mode 100644 lnbits/extensions/lnurlp/helpers.py create mode 100644 lnbits/utils/__init__.py create mode 100644 lnbits/utils/exchange_rates.py diff --git a/lnbits/extensions/lnurlp/helpers.py b/lnbits/extensions/lnurlp/helpers.py deleted file mode 100644 index 13e748574..000000000 --- a/lnbits/extensions/lnurlp/helpers.py +++ /dev/null @@ -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) diff --git a/lnbits/extensions/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py index 74dd6e352..31b855595 100644 --- a/lnbits/extensions/lnurlp/lnurl.py +++ b/lnbits/extensions/lnurlp/lnurl.py @@ -5,10 +5,10 @@ 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 get_fiat_rate_satoshis from . import lnurlp_ext from .crud import increment_pay_link -from .helpers import get_fiat_rate @lnurlp_ext.route("/api/v1/lnurl/", methods=["GET"]) @@ -17,7 +17,7 @@ async def api_lnurl_response(link_id): if not link: 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( callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True), 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 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: # allow some fluctuation (as the fiat price may have changed between the calls) min = rate * 995 * link.min diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py index f68bc5b30..a0fe2ffd8 100644 --- a/lnbits/extensions/lnurlp/views_api.py +++ b/lnbits/extensions/lnurlp/views_api.py @@ -4,6 +4,7 @@ from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore from lnbits.core.crud import get_user from lnbits.decorators import api_check_wallet_key, api_validate_post_request +from lnbits.utils.exchange_rates import get_fiat_rate_satoshis from . import lnurlp_ext from .crud import ( @@ -13,7 +14,6 @@ from .crud import ( update_pay_link, delete_pay_link, ) -from .helpers import get_fiat_rate @lnurlp_ext.route("/api/v1/links", methods=["GET"]) @@ -109,7 +109,7 @@ async def api_link_delete(link_id): @lnurlp_ext.route("/api/v1/rate/", methods=["GET"]) async def api_check_fiat_rate(currency): try: - rate = await get_fiat_rate(currency) + rate = await get_fiat_rate_satoshis(currency) except AssertionError: rate = None diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py index 2930809dc..6b56cf559 100644 --- a/lnbits/extensions/offlineshop/helpers.py +++ b/lnbits/extensions/offlineshop/helpers.py @@ -1,57 +1,9 @@ -import trio # type: ignore -import httpx import base64 import struct import hmac import time -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) - - def hotp(key, counter, digits=6, digest="sha1"): key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8)) counter = struct.pack(">Q", counter) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index 7abf070e1..d1e11c0c5 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -3,10 +3,10 @@ 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 -from .helpers import get_fiat_rate @offlineshop_ext.route("/lnurl/", methods=["GET"]) @@ -18,8 +18,7 @@ async def lnurl_response(item_id): if not item.enabled: return jsonify({"status": "ERROR", "reason": "Item disabled."}) - rate = await get_fiat_rate(item.unit) if item.unit != "sat" else 1 - price_msat = int(item.price * rate) * 1000 + 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), @@ -41,10 +40,10 @@ async def lnurl_callback(item_id): min = item.price * 1000 max = item.price * 1000 else: - rate = await get_fiat_rate(item.unit) + price = await fiat_amount_as_satoshis(item.price, item.unit) # allow some fluctuation (the fiat price may have changed between the calls) - min = int(rate * item.price) * 995 - max = int(rate * item.price) * 1010 + min = price * 995 + max = price * 1010 amount_received = int(request.args.get("amount")) if amount_received < min: diff --git a/lnbits/utils/__init__.py b/lnbits/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py new file mode 100644 index 000000000..506f6daf0 --- /dev/null +++ b/lnbits/utils/exchange_rates.py @@ -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))) From 0bc66ea15052572bbbd0016c851967c676502cb9 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 14 Mar 2021 20:58:49 -0300 Subject: [PATCH 13/14] support all the currencies. --- lnbits/extensions/offlineshop/static/js/index.js | 11 ++++++++++- lnbits/extensions/offlineshop/views_api.py | 8 +++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index f7dbba9bc..00e932416 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -24,7 +24,7 @@ new Vue({ itemDialog: { show: false, data: {...defaultItemData}, - units: ['sat', 'USD'] + units: ['sat'] } } }, @@ -207,5 +207,14 @@ new Vue({ 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) + }) } }) diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index fd60014a4..20a9eced0 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -3,6 +3,7 @@ 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 ( @@ -16,6 +17,11 @@ from .crud import ( 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(): @@ -51,7 +57,7 @@ async def api_shop_from_wallet(): "description": {"type": "string", "empty": False, "required": True}, "image": {"type": "string", "required": False, "nullable": True}, "price": {"type": "number", "required": True}, - "unit": {"type": "string", "allowed": ["sat", "USD"], "required": True}, + "unit": {"type": "string", "required": True}, } ) async def api_add_or_update_item(item_id=None): From 1bc59974a8ec327c9ca6f57deb9d956f0fa55de8 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 14 Mar 2021 21:50:05 -0300 Subject: [PATCH 14/14] also support all currencies in lnurlp. --- lnbits/extensions/lnurlp/static/js/index.js | 10 ++++++++++ lnbits/extensions/lnurlp/templates/lnurlp/index.html | 2 +- lnbits/extensions/lnurlp/views_api.py | 9 +++++++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lnbits/extensions/lnurlp/static/js/index.js b/lnbits/extensions/lnurlp/static/js/index.js index bbf5baf08..dbc0df1e3 100644 --- a/lnbits/extensions/lnurlp/static/js/index.js +++ b/lnbits/extensions/lnurlp/static/js/index.js @@ -26,6 +26,7 @@ new Vue({ mixins: [windowMixin], data() { return { + currencies: [], fiatRates: {}, checker: null, payLinks: [], @@ -203,5 +204,14 @@ new Vue({ getPayLinks() }, 20000) } + + LNbits.api + .request('GET', '/lnurlp/api/v1/currencies') + .then(response => { + this.currencies = ['satoshis', ...response.data] + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) } }) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html index c235dc7f3..c7d60667b 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -182,7 +182,7 @@