diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 5b0d572c..2c91bc22 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 hash = ? + LIMIT 1 """, - (checking_id,), + (checking_id_or_hash, checking_id_or_hash), ) return Payment.from_row(row) if row else None diff --git a/lnbits/extensions/lnurlp/helpers.py b/lnbits/extensions/lnurlp/helpers.py deleted file mode 100644 index 13e74857..00000000 --- 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 74dd6e35..31b85559 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/static/js/index.js b/lnbits/extensions/lnurlp/static/js/index.js index bbf5baf0..dbc0df1e 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 79c44a50..c7d60667 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -133,7 +133,7 @@ - + ", 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/README.md b/lnbits/extensions/offlineshop/README.md new file mode 100644 index 00000000..254bc688 --- /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 00000000..e24fa14b --- /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 00000000..507b1d14 --- /dev/null +++ b/lnbits/extensions/offlineshop/config.json @@ -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" + ] +} diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py new file mode 100644 index 00000000..365015a3 --- /dev/null +++ b/lnbits/extensions/offlineshop/crud.py @@ -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), + ) diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py new file mode 100644 index 00000000..6b56cf55 --- /dev/null +++ b/lnbits/extensions/offlineshop/helpers.py @@ -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) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py new file mode 100644 index 00000000..d1e11c0c --- /dev/null +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -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/", 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/", 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()) diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py new file mode 100644 index 00000000..8e8a4877 --- /dev/null +++ b/lnbits/extensions/offlineshop/migrations.py @@ -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' + ); + """ + ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py new file mode 100644 index 00000000..52cf95f2 --- /dev/null +++ b/lnbits/extensions/offlineshop/models.py @@ -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.", + ) diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js new file mode 100644 index 00000000..00e93241 --- /dev/null +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -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) + }) + } +}) 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 00000000..1e3bf051 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -0,0 +1,147 @@ + + + +
    +
  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. +
+

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

+
+
+
+ + + + + + 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">}' + +
+
+
+ + + + GET +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {"id": <integer>, "wallet": <string>, "wordlist": + <string>, "items": [{"id": <integer>, "name": + <string>, "description": <string>, "image": + <string>, "enabled": <boolean>, "price": <integer>, + "unit": <string>, "lnurl": <string>}, ...]}< +
Curl example
+ curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + 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 }}" + +
+
+
+
diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html new file mode 100644 index 00000000..6dfbc993 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -0,0 +1,332 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+
+
Items
+
+
+ Add new item +
+
+ {% raw %} + + + + + {% endraw %} +
+
+ + + +
+
Wallet Shop
+
+ + + + + + +
+ Print QR Codes +
+
+
+ + + + + + + + + + +
+
+ +
+
+ + Update Wordlist + + Reset +
+
+
+ +
+
+
+ + + +
+
+ + 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 + +
+
+
+
+ +
+ + +
LNbits OfflineShop extension
+
+ + + {% include "offlineshop/_api_docs.html" %} + +
+
+ + + + +
+
Adding a new item
+ + + + + +
+ Copy LNURL +
+ + + + + + + + + + +
+
+ + {% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %} + Item + +
+
+ Cancel +
+
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/print.html b/lnbits/extensions/offlineshop/templates/offlineshop/print.html new file mode 100644 index 00000000..fff12b4c --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/print.html @@ -0,0 +1,25 @@ +{% extends "print.html" %} {% block page %} {% raw %} +
+
+
{{ item.name }}
+ +
{{ item.price }}
+
+
+{% endraw %} {% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py new file mode 100644 index 00000000..aa4ac3a1 --- /dev/null +++ b/lnbits/extensions/offlineshop/views.py @@ -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 = "" + + 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)}]
+{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 new file mode 100644 index 00000000..20a9eced --- /dev/null +++ b/lnbits/extensions/offlineshop/views_api.py @@ -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/", 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/", 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 diff --git a/lnbits/extensions/offlineshop/wordlists.py b/lnbits/extensions/offlineshop/wordlists.py new file mode 100644 index 00000000..ee3663e3 --- /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", +] diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html index 3884c3b5..1157fa46 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 7b1925a5..fbd13e72 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 bc1aac2b..18a0a542 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 }}" diff --git a/lnbits/utils/__init__.py b/lnbits/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py new file mode 100644 index 00000000..506f6daf --- /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)))