From 4653e81695f3cc828a208c723f002682eb1e17ed Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Tue, 26 Oct 2021 16:48:04 +0100 Subject: [PATCH] blesko initial commit --- lnbits/extensions/bleskomat/README.md | 21 ++ lnbits/extensions/bleskomat/__init__.py | 12 + lnbits/extensions/bleskomat/config.json | 6 + lnbits/extensions/bleskomat/crud.py | 119 ++++++++++ lnbits/extensions/bleskomat/exchange_rates.py | 79 +++++++ .../extensions/bleskomat/fiat_currencies.json | 166 ++++++++++++++ lnbits/extensions/bleskomat/helpers.py | 153 +++++++++++++ lnbits/extensions/bleskomat/lnurl_api.py | 134 +++++++++++ lnbits/extensions/bleskomat/migrations.py | 37 +++ lnbits/extensions/bleskomat/models.py | 110 +++++++++ .../extensions/bleskomat/static/js/index.js | 216 ++++++++++++++++++ .../templates/bleskomat/_api_docs.html | 65 ++++++ .../bleskomat/templates/bleskomat/index.html | 180 +++++++++++++++ lnbits/extensions/bleskomat/views.py | 22 ++ lnbits/extensions/bleskomat/views_api.py | 120 ++++++++++ 15 files changed, 1440 insertions(+) create mode 100644 lnbits/extensions/bleskomat/README.md create mode 100644 lnbits/extensions/bleskomat/__init__.py create mode 100644 lnbits/extensions/bleskomat/config.json create mode 100644 lnbits/extensions/bleskomat/crud.py create mode 100644 lnbits/extensions/bleskomat/exchange_rates.py create mode 100644 lnbits/extensions/bleskomat/fiat_currencies.json create mode 100644 lnbits/extensions/bleskomat/helpers.py create mode 100644 lnbits/extensions/bleskomat/lnurl_api.py create mode 100644 lnbits/extensions/bleskomat/migrations.py create mode 100644 lnbits/extensions/bleskomat/models.py create mode 100644 lnbits/extensions/bleskomat/static/js/index.js create mode 100644 lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html create mode 100644 lnbits/extensions/bleskomat/templates/bleskomat/index.html create mode 100644 lnbits/extensions/bleskomat/views.py create mode 100644 lnbits/extensions/bleskomat/views_api.py diff --git a/lnbits/extensions/bleskomat/README.md b/lnbits/extensions/bleskomat/README.md new file mode 100644 index 00000000..97c70700 --- /dev/null +++ b/lnbits/extensions/bleskomat/README.md @@ -0,0 +1,21 @@ +# Bleskomat Extension for lnbits + +This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/). + + +## Connect Your Bleskomat ATM + +* Click the "Add Bleskomat" button on this page to begin. +* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers. +* Choose the fiat currency. This should match the fiat currency that your ATM accepts. +* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds. +* Set your ATM's fee percentage. +* Click the "Done" button. +* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM. +* Copy the configuration file ("bleskomat.conf") to your ATM's SD card. +* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card. + + +## How Does It Work? + +Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet. diff --git a/lnbits/extensions/bleskomat/__init__.py b/lnbits/extensions/bleskomat/__init__.py new file mode 100644 index 00000000..42f9bb46 --- /dev/null +++ b/lnbits/extensions/bleskomat/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_bleskomat") + +bleskomat_ext: Blueprint = Blueprint( + "bleskomat", __name__, static_folder="static", template_folder="templates" +) + +from .lnurl_api import * # noqa +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/bleskomat/config.json b/lnbits/extensions/bleskomat/config.json new file mode 100644 index 00000000..99244df1 --- /dev/null +++ b/lnbits/extensions/bleskomat/config.json @@ -0,0 +1,6 @@ +{ + "name": "Bleskomat", + "short_description": "Connect a Bleskomat ATM to an lnbits", + "icon": "money", + "contributors": ["chill117"] +} diff --git a/lnbits/extensions/bleskomat/crud.py b/lnbits/extensions/bleskomat/crud.py new file mode 100644 index 00000000..1cc44576 --- /dev/null +++ b/lnbits/extensions/bleskomat/crud.py @@ -0,0 +1,119 @@ +import secrets +import time +from uuid import uuid4 +from typing import List, Optional, Union +from . import db +from .models import Bleskomat, BleskomatLnurl +from .helpers import generate_bleskomat_lnurl_hash + + +async def create_bleskomat( + *, + wallet_id: str, + name: str, + fiat_currency: str, + exchange_rate_provider: str, + fee: str, +) -> Bleskomat: + bleskomat_id = uuid4().hex + api_key_id = secrets.token_hex(8) + api_key_secret = secrets.token_hex(32) + api_key_encoding = "hex" + await db.execute( + """ + INSERT INTO bleskomat.bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bleskomat_id, + wallet_id, + api_key_id, + api_key_secret, + api_key_encoding, + name, + fiat_currency, + exchange_rate_provider, + fee, + ), + ) + bleskomat = await get_bleskomat(bleskomat_id) + assert bleskomat, "Newly created bleskomat couldn't be retrieved" + return bleskomat + + +async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]: + row = await db.fetchone( + "SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,) + ) + return Bleskomat(**row) if row else None + + +async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]: + row = await db.fetchone( + "SELECT * FROM bleskomat.bleskomats WHERE api_key_id = ?", (api_key_id,) + ) + return Bleskomat(**row) if row else None + + +async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM bleskomat.bleskomats WHERE wallet IN ({q})", (*wallet_ids,) + ) + return [Bleskomat(**row) for row in rows] + + +async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE bleskomat.bleskomats SET {q} WHERE id = ?", + (*kwargs.values(), bleskomat_id), + ) + row = await db.fetchone( + "SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,) + ) + return Bleskomat(**row) if row else None + + +async def delete_bleskomat(bleskomat_id: str) -> None: + await db.execute("DELETE FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)) + + +async def create_bleskomat_lnurl( + *, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1 +) -> BleskomatLnurl: + bleskomat_lnurl_id = uuid4().hex + hash = generate_bleskomat_lnurl_hash(secret) + now = int(time.time()) + await db.execute( + """ + INSERT INTO bleskomat.bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bleskomat_lnurl_id, + bleskomat.id, + bleskomat.wallet, + hash, + tag, + params, + bleskomat.api_key_id, + uses, + uses, + now, + now, + ), + ) + bleskomat_lnurl = await get_bleskomat_lnurl(secret) + assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved" + return bleskomat_lnurl + + +async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]: + hash = generate_bleskomat_lnurl_hash(secret) + row = await db.fetchone( + "SELECT * FROM bleskomat.bleskomat_lnurls WHERE hash = ?", (hash,) + ) + return BleskomatLnurl(**row) if row else None diff --git a/lnbits/extensions/bleskomat/exchange_rates.py b/lnbits/extensions/bleskomat/exchange_rates.py new file mode 100644 index 00000000..928a2823 --- /dev/null +++ b/lnbits/extensions/bleskomat/exchange_rates.py @@ -0,0 +1,79 @@ +import httpx +import json +import os + +fiat_currencies = json.load( + open( + os.path.join( + os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json" + ), + "r", + ) +) + +exchange_rate_providers = { + "bitfinex": { + "name": "Bitfinex", + "domain": "bitfinex.com", + "api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}", + "getter": lambda data, replacements: data["last_price"], + }, + "bitstamp": { + "name": "Bitstamp", + "domain": "bitstamp.net", + "api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/", + "getter": lambda data, replacements: data["last"], + }, + "coinbase": { + "name": "Coinbase", + "domain": "coinbase.com", + "api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}", + "getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]], + }, + "coinmate": { + "name": "CoinMate", + "domain": "coinmate.io", + "api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}", + "getter": lambda data, replacements: data["data"]["last"], + }, + "kraken": { + "name": "Kraken", + "domain": "kraken.com", + "api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}", + "getter": lambda data, replacements: data["result"][ + "XXBTZ" + replacements["TO"] + ]["c"][0], + }, +} + +exchange_rate_providers_serializable = {} +for ref, exchange_rate_provider in exchange_rate_providers.items(): + exchange_rate_provider_serializable = {} + for key, value in exchange_rate_provider.items(): + if not callable(value): + exchange_rate_provider_serializable[key] = value + exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable + + +async def fetch_fiat_exchange_rate(currency: str, provider: str): + + replacements = { + "FROM": "BTC", + "from": "btc", + "TO": currency.upper(), + "to": currency.lower(), + } + + url = exchange_rate_providers[provider]["api_url"] + for key in replacements.keys(): + url = url.replace("{" + key + "}", replacements[key]) + + getter = exchange_rate_providers[provider]["getter"] + + async with httpx.AsyncClient() as client: + r = await client.get(url) + r.raise_for_status() + data = r.json() + rate = float(getter(data, replacements)) + + return rate diff --git a/lnbits/extensions/bleskomat/fiat_currencies.json b/lnbits/extensions/bleskomat/fiat_currencies.json new file mode 100644 index 00000000..ff831f3e --- /dev/null +++ b/lnbits/extensions/bleskomat/fiat_currencies.json @@ -0,0 +1,166 @@ +{ + "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" +} \ No newline at end of file diff --git a/lnbits/extensions/bleskomat/helpers.py b/lnbits/extensions/bleskomat/helpers.py new file mode 100644 index 00000000..a3857b77 --- /dev/null +++ b/lnbits/extensions/bleskomat/helpers.py @@ -0,0 +1,153 @@ +import base64 +import hashlib +import hmac +from http import HTTPStatus +from binascii import unhexlify +from typing import Dict +from quart import url_for +import urllib + + +def generate_bleskomat_lnurl_hash(secret: str): + m = hashlib.sha256() + m.update(f"{secret}".encode()) + return m.hexdigest() + + +def generate_bleskomat_lnurl_signature( + payload: str, api_key_secret: str, api_key_encoding: str = "hex" +): + if api_key_encoding == "hex": + key = unhexlify(api_key_secret) + elif api_key_encoding == "base64": + key = base64.b64decode(api_key_secret) + else: + key = bytes(f"{api_key_secret}") + return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest() + + +def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str): + # The secret is not randomly generated by the server. + # Instead it is the hash of the API key ID and signature concatenated together. + m = hashlib.sha256() + m.update(f"{api_key_id}-{signature}".encode()) + return m.hexdigest() + + +def get_callback_url(): + return url_for("bleskomat.api_bleskomat_lnurl", _external=True) + + +def is_supported_lnurl_subprotocol(tag: str) -> bool: + return tag == "withdrawRequest" + + +class LnurlHttpError(Exception): + def __init__( + self, + message: str = "", + http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR, + ): + self.message = message + self.http_status = http_status + super().__init__(self.message) + + +class LnurlValidationError(Exception): + pass + + +def prepare_lnurl_params(tag: str, query: Dict[str, str]): + params = {} + if not is_supported_lnurl_subprotocol(tag): + raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"') + if tag == "withdrawRequest": + params["minWithdrawable"] = float(query["minWithdrawable"]) + params["maxWithdrawable"] = float(query["maxWithdrawable"]) + params["defaultDescription"] = query["defaultDescription"] + if not params["minWithdrawable"] > 0: + raise LnurlValidationError('"minWithdrawable" must be greater than zero') + if not params["maxWithdrawable"] >= params["minWithdrawable"]: + raise LnurlValidationError( + '"maxWithdrawable" must be greater than or equal to "minWithdrawable"' + ) + return params + + +encode_uri_component_safe_chars = ( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()" +) + + +def query_to_signing_payload(query: Dict[str, str]) -> str: + # Sort the query by key, then stringify it to create the payload. + sorted_keys = sorted(query.keys(), key=str.lower) + payload = [] + for key in sorted_keys: + if not key == "signature": + encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars) + encoded_value = urllib.parse.quote( + query[key], safe=encode_uri_component_safe_chars + ) + payload.append(f"{encoded_key}={encoded_value}") + return "&".join(payload) + + +unshorten_rules = { + "query": {"n": "nonce", "s": "signature", "t": "tag"}, + "tags": { + "c": "channelRequest", + "l": "login", + "p": "payRequest", + "w": "withdrawRequest", + }, + "params": { + "channelRequest": {"pl": "localAmt", "pp": "pushAmt"}, + "login": {}, + "payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"}, + "withdrawRequest": { + "pn": "minWithdrawable", + "px": "maxWithdrawable", + "pd": "defaultDescription", + }, + }, +} + + +def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]: + new_query = {} + rules = unshorten_rules + if "tag" in query: + tag = query["tag"] + elif "t" in query: + tag = query["t"] + else: + raise LnurlValidationError('Missing required query parameter: "tag"') + # Unshorten tag: + if tag in rules["tags"]: + long_tag = rules["tags"][tag] + new_query["tag"] = long_tag + tag = long_tag + if not tag in rules["params"]: + raise LnurlValidationError(f'Unknown tag: "{tag}"') + for key in query: + if key in rules["params"][tag]: + short_param_key = key + long_param_key = rules["params"][tag][short_param_key] + if short_param_key in query: + new_query[long_param_key] = query[short_param_key] + else: + new_query[long_param_key] = query[long_param_key] + elif key in rules["query"]: + # Unshorten general keys: + short_key = key + long_key = rules["query"][short_key] + if not long_key in new_query: + if short_key in query: + new_query[long_key] = query[short_key] + else: + new_query[long_key] = query[long_key] + else: + # Keep unknown key/value pairs unchanged: + new_query[key] = query[key] + return new_query diff --git a/lnbits/extensions/bleskomat/lnurl_api.py b/lnbits/extensions/bleskomat/lnurl_api.py new file mode 100644 index 00000000..086562d1 --- /dev/null +++ b/lnbits/extensions/bleskomat/lnurl_api.py @@ -0,0 +1,134 @@ +import json +import math +from quart import jsonify, request +from http import HTTPStatus +import traceback + +from . import bleskomat_ext +from .crud import ( + create_bleskomat_lnurl, + get_bleskomat_by_api_key_id, + get_bleskomat_lnurl, +) + +from .exchange_rates import ( + fetch_fiat_exchange_rate, +) + +from .helpers import ( + generate_bleskomat_lnurl_signature, + generate_bleskomat_lnurl_secret, + LnurlHttpError, + LnurlValidationError, + prepare_lnurl_params, + query_to_signing_payload, + unshorten_lnurl_query, +) + + +# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs. +@bleskomat_ext.route("/u", methods=["GET"]) +async def api_bleskomat_lnurl(): + try: + query = request.args.to_dict() + + # Unshorten query if "s" is used instead of "signature". + if "s" in query: + query = unshorten_lnurl_query(query) + + if "signature" in query: + + # Signature provided. + # Use signature to verify that the URL was generated by an authorized device. + # Later validate parameters, auto-generate LNURL, reply with LNURL response object. + signature = query["signature"] + + # The API key ID, nonce, and tag should be present in the query string. + for field in ["id", "nonce", "tag"]: + if not field in query: + raise LnurlHttpError( + f'Failed API key signature check: Missing "{field}"', + HTTPStatus.BAD_REQUEST, + ) + + # URL signing scheme is described here: + # https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme + payload = query_to_signing_payload(query) + api_key_id = query["id"] + bleskomat = await get_bleskomat_by_api_key_id(api_key_id) + if not bleskomat: + raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST) + api_key_secret = bleskomat.api_key_secret + api_key_encoding = bleskomat.api_key_encoding + expected_signature = generate_bleskomat_lnurl_signature( + payload, api_key_secret, api_key_encoding + ) + if signature != expected_signature: + raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN) + + # Signature is valid. + # In the case of signed URLs, the secret is deterministic based on the API key ID and signature. + secret = generate_bleskomat_lnurl_secret(api_key_id, signature) + lnurl = await get_bleskomat_lnurl(secret) + if not lnurl: + try: + tag = query["tag"] + params = prepare_lnurl_params(tag, query) + if "f" in query: + rate = await fetch_fiat_exchange_rate( + currency=query["f"], + provider=bleskomat.exchange_rate_provider, + ) + # Convert fee (%) to decimal: + fee = float(bleskomat.fee) / 100 + if tag == "withdrawRequest": + for key in ["minWithdrawable", "maxWithdrawable"]: + amount_sats = int( + math.floor((params[key] / rate) * 1e8) + ) + fee_sats = int(math.floor(amount_sats * fee)) + amount_sats_less_fee = amount_sats - fee_sats + # Convert to msats: + params[key] = int(amount_sats_less_fee * 1e3) + except LnurlValidationError as e: + raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST) + # Create a new LNURL using the query parameters provided in the signed URL. + params = json.JSONEncoder().encode(params) + lnurl = await create_bleskomat_lnurl( + bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1 + ) + + # Reply with LNURL response object. + return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK + + # No signature provided. + # Treat as "action" callback. + + if not "k1" in query: + raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST) + + secret = query["k1"] + lnurl = await get_bleskomat_lnurl(secret) + if not lnurl: + raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST) + + if not lnurl.has_uses_remaining(): + raise LnurlHttpError( + "Maximum number of uses already reached", HTTPStatus.BAD_REQUEST + ) + + try: + await lnurl.execute_action(query) + except LnurlValidationError as e: + raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST) + + except LnurlHttpError as e: + return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status + except Exception: + traceback.print_exc() + return ( + jsonify({"status": "ERROR", "reason": "Unexpected error"}), + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return jsonify({"status": "OK"}), HTTPStatus.OK diff --git a/lnbits/extensions/bleskomat/migrations.py b/lnbits/extensions/bleskomat/migrations.py new file mode 100644 index 00000000..84e886e5 --- /dev/null +++ b/lnbits/extensions/bleskomat/migrations.py @@ -0,0 +1,37 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE bleskomat.bleskomats ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + api_key_id TEXT NOT NULL, + api_key_secret TEXT NOT NULL, + api_key_encoding TEXT NOT NULL, + name TEXT NOT NULL, + fiat_currency TEXT NOT NULL, + exchange_rate_provider TEXT NOT NULL, + fee TEXT NOT NULL, + UNIQUE(api_key_id) + ); + """ + ) + + await db.execute( + """ + CREATE TABLE bleskomat.bleskomat_lnurls ( + id TEXT PRIMARY KEY, + bleskomat TEXT NOT NULL, + wallet TEXT NOT NULL, + hash TEXT NOT NULL, + tag TEXT NOT NULL, + params TEXT NOT NULL, + api_key_id TEXT NOT NULL, + initial_uses INTEGER DEFAULT 1, + remaining_uses INTEGER DEFAULT 0, + created_time INTEGER, + updated_time INTEGER, + UNIQUE(hash) + ); + """ + ) diff --git a/lnbits/extensions/bleskomat/models.py b/lnbits/extensions/bleskomat/models.py new file mode 100644 index 00000000..216f83c6 --- /dev/null +++ b/lnbits/extensions/bleskomat/models.py @@ -0,0 +1,110 @@ +import json +import time +from typing import NamedTuple, Dict +from lnbits import bolt11 +from lnbits.core.services import pay_invoice +from . import db +from .helpers import get_callback_url, LnurlValidationError + + +class Bleskomat(NamedTuple): + id: str + wallet: str + api_key_id: str + api_key_secret: str + api_key_encoding: str + name: str + fiat_currency: str + exchange_rate_provider: str + fee: str + + +class BleskomatLnurl(NamedTuple): + id: str + bleskomat: str + wallet: str + hash: str + tag: str + params: str + api_key_id: str + initial_uses: int + remaining_uses: int + created_time: int + updated_time: int + + def has_uses_remaining(self) -> bool: + # When initial uses is 0 then the LNURL has unlimited uses. + return self.initial_uses == 0 or self.remaining_uses > 0 + + def get_info_response_object(self, secret: str) -> Dict[str, str]: + tag = self.tag + params = json.loads(self.params) + response = {"tag": tag} + if tag == "withdrawRequest": + for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]: + response[key] = params[key] + response["callback"] = get_callback_url() + response["k1"] = secret + return response + + def validate_action(self, query: Dict[str, str]) -> None: + tag = self.tag + params = json.loads(self.params) + # Perform tag-specific checks. + if tag == "withdrawRequest": + for field in ["pr"]: + if not field in query: + raise LnurlValidationError(f'Missing required parameter: "{field}"') + # Check the bolt11 invoice(s) provided. + pr = query["pr"] + if "," in pr: + raise LnurlValidationError("Multiple payment requests not supported") + try: + invoice = bolt11.decode(pr) + except ValueError: + raise LnurlValidationError( + 'Invalid parameter ("pr"): Lightning payment request expected' + ) + if invoice.amount_msat < params["minWithdrawable"]: + raise LnurlValidationError( + 'Amount in invoice must be greater than or equal to "minWithdrawable"' + ) + if invoice.amount_msat > params["maxWithdrawable"]: + raise LnurlValidationError( + 'Amount in invoice must be less than or equal to "maxWithdrawable"' + ) + else: + raise LnurlValidationError(f'Unknown subprotocol: "{tag}"') + + async def execute_action(self, query: Dict[str, str]): + self.validate_action(query) + used = False + async with db.connect() as conn: + if self.initial_uses > 0: + used = await self.use(conn) + if not used: + raise LnurlValidationError("Maximum number of uses already reached") + tag = self.tag + if tag == "withdrawRequest": + try: + payment_hash = await pay_invoice( + wallet_id=self.wallet, + payment_request=query["pr"], + ) + except Exception: + raise LnurlValidationError("Failed to pay invoice") + if not payment_hash: + raise LnurlValidationError("Failed to pay invoice") + + async def use(self, conn) -> bool: + now = int(time.time()) + result = await conn.execute( + """ + UPDATE bleskomat.bleskomat_lnurls + SET remaining_uses = remaining_uses - 1, updated_time = ? + WHERE id = ? + AND remaining_uses > 0 + """, + (now, self.id), + ) + return result.rowcount > 0 diff --git a/lnbits/extensions/bleskomat/static/js/index.js b/lnbits/extensions/bleskomat/static/js/index.js new file mode 100644 index 00000000..fd166ff3 --- /dev/null +++ b/lnbits/extensions/bleskomat/static/js/index.js @@ -0,0 +1,216 @@ +/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */ + +Vue.component(VueQrcode.name, VueQrcode) + +var mapBleskomat = function (obj) { + obj._data = _.clone(obj) + return obj +} + +var defaultValues = { + name: 'My Bleskomat', + fiat_currency: 'EUR', + exchange_rate_provider: 'coinbase', + fee: '0.00' +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + checker: null, + bleskomats: [], + bleskomatsTable: { + columns: [ + { + name: 'api_key_id', + align: 'left', + label: 'API Key ID', + field: 'api_key_id' + }, + { + name: 'name', + align: 'left', + label: 'Name', + field: 'name' + }, + { + name: 'fiat_currency', + align: 'left', + label: 'Fiat Currency', + field: 'fiat_currency' + }, + { + name: 'exchange_rate_provider', + align: 'left', + label: 'Exchange Rate Provider', + field: 'exchange_rate_provider' + }, + { + name: 'fee', + align: 'left', + label: 'Fee (%)', + field: 'fee' + } + ], + pagination: { + rowsPerPage: 10 + } + }, + formDialog: { + show: false, + fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies), + exchangeRateProviders: _.keys( + window.bleskomat_vars.exchange_rate_providers + ), + data: _.clone(defaultValues) + } + } + }, + computed: { + sortedBleskomats: function () { + return this.bleskomats.sort(function (a, b) { + // Sort by API Key ID alphabetically. + var apiKeyId_A = a.api_key_id.toLowerCase() + var apiKeyId_B = b.api_key_id.toLowerCase() + return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0 + }) + } + }, + methods: { + getBleskomats: function () { + var self = this + LNbits.api + .request( + 'GET', + '/bleskomat/api/v1/bleskomats?all_wallets', + this.g.user.wallets[0].adminkey + ) + .then(function (response) { + self.bleskomats = response.data.map(function (obj) { + return mapBleskomat(obj) + }) + }) + .catch(function (error) { + clearInterval(self.checker) + LNbits.utils.notifyApiError(error) + }) + }, + closeFormDialog: function () { + this.formDialog.data = _.clone(defaultValues) + }, + exportConfigFile: function (bleskomatId) { + var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) + var fieldToKey = { + api_key_id: 'apiKey.id', + api_key_secret: 'apiKey.key', + api_key_encoding: 'apiKey.encoding', + fiat_currency: 'fiatCurrency' + } + var lines = _.chain(bleskomat) + .map(function (value, field) { + var key = fieldToKey[field] || null + return key ? [key, value].join('=') : null + }) + .compact() + .value() + lines.push('callbackUrl=' + window.bleskomat_vars.callback_url) + lines.push('shorten=true') + var content = lines.join('\n') + var status = Quasar.utils.exportFile( + 'bleskomat.conf', + content, + 'text/plain' + ) + if (status !== true) { + Quasar.plugins.Notify.create({ + message: 'Browser denied file download...', + color: 'negative', + icon: null + }) + } + }, + openUpdateDialog: function (bleskomatId) { + var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) + this.formDialog.data = _.clone(bleskomat._data) + this.formDialog.show = true + }, + sendFormData: function () { + var wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + var data = _.omit(this.formDialog.data, 'wallet') + if (data.id) { + this.updateBleskomat(wallet, data) + } else { + this.createBleskomat(wallet, data) + } + }, + updateBleskomat: function (wallet, data) { + var self = this + LNbits.api + .request( + 'PUT', + '/bleskomat/api/v1/bleskomat/' + data.id, + wallet.adminkey, + _.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee') + ) + .then(function (response) { + self.bleskomats = _.reject(self.bleskomats, function (obj) { + return obj.id === data.id + }) + self.bleskomats.push(mapBleskomat(response.data)) + self.formDialog.show = false + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + createBleskomat: function (wallet, data) { + var self = this + LNbits.api + .request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data) + .then(function (response) { + self.bleskomats.push(mapBleskomat(response.data)) + self.formDialog.show = false + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + deleteBleskomat: function (bleskomatId) { + var self = this + var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) + LNbits.utils + .confirmDialog( + 'Are you sure you want to delete "' + bleskomat.name + '"?' + ) + .onOk(function () { + LNbits.api + .request( + 'DELETE', + '/bleskomat/api/v1/bleskomat/' + bleskomatId, + _.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey + ) + .then(function (response) { + self.bleskomats = _.reject(self.bleskomats, function (obj) { + return obj.id === bleskomatId + }) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }) + } + }, + created: function () { + if (this.g.user.wallets.length) { + var getBleskomats = this.getBleskomats + getBleskomats() + this.checker = setInterval(function () { + getBleskomats() + }, 20000) + } + } +}) diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html new file mode 100644 index 00000000..210d534c --- /dev/null +++ b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html @@ -0,0 +1,65 @@ + + + +

+ This extension allows you to connect a Bleskomat ATM to an lnbits + wallet. It will work with both the + open-source DIY Bleskomat ATM project + as well as the + commercial Bleskomat ATM. +

+
Connect Your Bleskomat ATM
+
+
    +
  1. Click the "Add Bleskomat" button on this page to begin.
  2. +
  3. + Choose a wallet. This will be the wallet that is used to pay + satoshis to your ATM customers. +
  4. +
  5. + Choose the fiat currency. This should match the fiat currency that + your ATM accepts. +
  6. +
  7. + Pick an exchange rate provider. This is the API that will be used to + query the fiat to satoshi exchange rate at the time your customer + attempts to withdraw their funds. +
  8. +
  9. Set your ATM's fee percentage.
  10. +
  11. Click the "Done" button.
  12. +
  13. + Find the new Bleskomat in the list and then click the export icon to + download a new configuration file for your ATM. +
  14. +
  15. + Copy the configuration file ("bleskomat.conf") to your ATM's SD + card. +
  16. +
  17. + Restart Your Bleskomat ATM. It should automatically reload the + configurations from the SD card. +
  18. +
+
+
How does it work?
+

+ Since the Bleskomat ATMs are designed to be offline, a cryptographic + signing scheme is used to verify that the URL was generated by an + authorized device. When one of your customers inserts fiat money into + the device, a signed URL (lnurl-withdraw) is created and displayed as a + QR code. Your customer scans the QR code with their lnurl-supporting + mobile app, their mobile app communicates with the web API of lnbits to + verify the signature, the fiat currency amount is converted to sats, the + customer accepts the withdrawal, and finally lnbits will pay the + customer from your lnbits wallet. +

+
+
+
diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/index.html b/lnbits/extensions/bleskomat/templates/bleskomat/index.html new file mode 100644 index 00000000..0cc51237 --- /dev/null +++ b/lnbits/extensions/bleskomat/templates/bleskomat/index.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} {% block page %} +
+
+ + + Add Bleskomat + + + + + +
+
+
Bleskomats
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} Bleskomat extension +
+
+ + + {% include "bleskomat/_api_docs.html" %} + +
+
+ + + + + + + + + + + + +
+ Update Bleskomat + Add Bleskomat + Cancel +
+
+
+
+
+{% endblock %} diff --git a/lnbits/extensions/bleskomat/views.py b/lnbits/extensions/bleskomat/views.py new file mode 100644 index 00000000..3a7f7263 --- /dev/null +++ b/lnbits/extensions/bleskomat/views.py @@ -0,0 +1,22 @@ +from quart import g, render_template + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import bleskomat_ext + +from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies +from .helpers import get_callback_url + + +@bleskomat_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + bleskomat_vars = { + "callback_url": get_callback_url(), + "exchange_rate_providers": exchange_rate_providers_serializable, + "fiat_currencies": fiat_currencies, + } + return await render_template( + "bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars + ) diff --git a/lnbits/extensions/bleskomat/views_api.py b/lnbits/extensions/bleskomat/views_api.py new file mode 100644 index 00000000..2971b066 --- /dev/null +++ b/lnbits/extensions/bleskomat/views_api.py @@ -0,0 +1,120 @@ +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import bleskomat_ext +from .crud import ( + create_bleskomat, + get_bleskomat, + get_bleskomats, + update_bleskomat, + delete_bleskomat, +) + +from .exchange_rates import ( + exchange_rate_providers, + fetch_fiat_exchange_rate, + fiat_currencies, +) + + +@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_bleskomats(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return ( + jsonify( + [bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)] + ), + HTTPStatus.OK, + ) + + +@bleskomat_ext.route("/api/v1/bleskomat/", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_bleskomat_retrieve(bleskomat_id): + bleskomat = await get_bleskomat(bleskomat_id) + + if not bleskomat or bleskomat.wallet != g.wallet.id: + return ( + jsonify({"message": "Bleskomat configuration not found."}), + HTTPStatus.NOT_FOUND, + ) + + return jsonify(bleskomat._asdict()), HTTPStatus.OK + + +@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"]) +@bleskomat_ext.route("/api/v1/bleskomat/", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "name": {"type": "string", "empty": False, "required": True}, + "fiat_currency": { + "type": "string", + "allowed": fiat_currencies.keys(), + "required": True, + }, + "exchange_rate_provider": { + "type": "string", + "allowed": exchange_rate_providers.keys(), + "required": True, + }, + "fee": {"type": ["string", "float", "number", "integer"], "required": True}, + } +) +async def api_bleskomat_create_or_update(bleskomat_id=None): + try: + fiat_currency = g.data["fiat_currency"] + exchange_rate_provider = g.data["exchange_rate_provider"] + await fetch_fiat_exchange_rate( + currency=fiat_currency, provider=exchange_rate_provider + ) + except Exception as e: + print(e) + return ( + jsonify( + { + "message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"' + } + ), + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + if bleskomat_id: + bleskomat = await get_bleskomat(bleskomat_id) + if not bleskomat or bleskomat.wallet != g.wallet.id: + return ( + jsonify({"message": "Bleskomat configuration not found."}), + HTTPStatus.NOT_FOUND, + ) + bleskomat = await update_bleskomat(bleskomat_id, **g.data) + else: + bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data) + + return ( + jsonify(bleskomat._asdict()), + HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED, + ) + + +@bleskomat_ext.route("/api/v1/bleskomat/", methods=["DELETE"]) +@api_check_wallet_key("admin") +async def api_bleskomat_delete(bleskomat_id): + bleskomat = await get_bleskomat(bleskomat_id) + + if not bleskomat or bleskomat.wallet != g.wallet.id: + return ( + jsonify({"message": "Bleskomat configuration not found."}), + HTTPStatus.NOT_FOUND, + ) + + await delete_bleskomat(bleskomat_id) + + return "", HTTPStatus.NO_CONTENT