From 286365326139ce98dd13112908c0987f676b9b2b Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Thu, 22 Oct 2020 15:58:15 -0300 Subject: [PATCH] lnurlp: accept comments, USD prices, min/max ranges. --- lnbits/extensions/lnurlp/crud.py | 77 ++-- lnbits/extensions/lnurlp/helpers.py | 48 +++ lnbits/extensions/lnurlp/lnurl.py | 59 +++- lnbits/extensions/lnurlp/migrations.py | 13 + lnbits/extensions/lnurlp/models.py | 5 +- lnbits/extensions/lnurlp/static/js/index.js | 207 +++++++++++ lnbits/extensions/lnurlp/tasks.py | 50 +-- .../lnurlp/templates/lnurlp/index.html | 328 +++++------------- lnbits/extensions/lnurlp/views_api.py | 28 +- .../withdraw/templates/withdraw/index.html | 2 +- 10 files changed, 495 insertions(+), 322 deletions(-) create mode 100644 lnbits/extensions/lnurlp/helpers.py create mode 100644 lnbits/extensions/lnurlp/static/js/index.js diff --git a/lnbits/extensions/lnurlp/crud.py b/lnbits/extensions/lnurlp/crud.py index eb151f91e..1a1b84e86 100644 --- a/lnbits/extensions/lnurlp/crud.py +++ b/lnbits/extensions/lnurlp/crud.py @@ -1,7 +1,9 @@ +import json from typing import List, Optional, Union -from lnbits import bolt11 from lnbits.db import open_ext_db +from lnbits.core.models import Payment +from quart import g from .models import PayLink @@ -10,7 +12,10 @@ def create_pay_link( *, wallet_id: str, description: str, - amount: int, + min: int, + max: int, + comment_chars: int = 0, + currency: Optional[str] = None, webhook_url: Optional[str] = None, success_text: Optional[str] = None, success_url: Optional[str] = None, @@ -21,16 +26,29 @@ def create_pay_link( INSERT INTO pay_links ( wallet, description, - amount, + min, + max, served_meta, served_pr, webhook_url, success_text, - success_url + success_url, + comment_chars, + currency ) - VALUES (?, ?, ?, 0, 0, ?, ?, ?) + VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?) """, - (wallet_id, description, amount, webhook_url, success_text, success_url), + ( + wallet_id, + description, + min, + max, + webhook_url, + success_text, + success_url, + comment_chars, + currency, + ), ) link_id = db.cursor.lastrowid return get_pay_link(link_id) @@ -43,22 +61,6 @@ def get_pay_link(link_id: int) -> Optional[PayLink]: return PayLink.from_row(row) if row else None -def get_pay_link_by_invoice(payment_hash: str) -> Optional[PayLink]: - # this excludes invoices with webhooks that have been sent already - - with open_ext_db("lnurlp") as db: - row = db.fetchone( - """ - SELECT pay_links.* FROM pay_links - INNER JOIN invoices ON invoices.pay_link = pay_links.id - WHERE payment_hash = ? AND webhook_sent IS NULL - """, - (payment_hash,), - ) - - return PayLink.from_row(row) if row else None - - def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] @@ -101,25 +103,12 @@ def delete_pay_link(link_id: int) -> None: db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,)) -def save_link_invoice(link_id: int, payment_request: str) -> None: - inv = bolt11.decode(payment_request) - - with open_ext_db("lnurlp") as db: - db.execute( - """ - INSERT INTO invoices (pay_link, payment_hash, expiry) - VALUES (?, ?, ?) - """, - (link_id, inv.payment_hash, inv.expiry), - ) - - -def mark_webhook_sent(payment_hash: str, status: int) -> None: - with open_ext_db("lnurlp") as db: - db.execute( - """ - UPDATE invoices SET webhook_sent = ? - WHERE payment_hash = ? - """, - (status, payment_hash), - ) +def mark_webhook_sent(payment: Payment, status: int) -> None: + payment.extra["wh_status"] = status + g.db.execute( + """ + UPDATE apipayments SET extra = ? + WHERE hash = ? + """, + (json.dumps(payment.extra), payment.payment_hash), + ) diff --git a/lnbits/extensions/lnurlp/helpers.py b/lnbits/extensions/lnurlp/helpers.py new file mode 100644 index 000000000..13e748574 --- /dev/null +++ b/lnbits/extensions/lnurlp/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/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py index e77d02df5..7b8d43622 100644 --- a/lnbits/extensions/lnurlp/lnurl.py +++ b/lnbits/extensions/lnurlp/lnurl.py @@ -1,12 +1,14 @@ import hashlib +import math from http import HTTPStatus -from quart import jsonify, url_for -from lnurl import LnurlPayResponse, LnurlPayActionResponse +from quart import jsonify, url_for, request +from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore from lnbits.core.services import create_invoice from . import lnurlp_ext -from .crud import increment_pay_link, save_link_invoice +from .crud import increment_pay_link +from .helpers import get_fiat_rate @lnurlp_ext.route("/api/v1/lnurl/", methods=["GET"]) @@ -15,16 +17,19 @@ async def api_lnurl_response(link_id): if not link: return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK - url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True) - + rate = await get_fiat_rate(link.currency) if link.currency else 1 resp = LnurlPayResponse( - callback=url, - min_sendable=link.amount * 1000, - max_sendable=link.amount * 1000, + callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True), + min_sendable=math.ceil(link.min * rate) * 1000, + max_sendable=round(link.max * rate) * 1000, metadata=link.lnurlpay_metadata, ) + params = resp.dict() - return jsonify(resp.dict()), HTTPStatus.OK + if link.comment_chars > 0: + params["commentAllowed"] = link.comment_chars + + return jsonify(params), HTTPStatus.OK @lnurlp_ext.route("/api/v1/lnurl/cb/", methods=["GET"]) @@ -33,16 +38,44 @@ async def api_lnurl_callback(link_id): if not link: 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 + if link.currency: + # allow some fluctuation (as the fiat price may have changed between the calls) + min = rate * 995 * link.min + max = rate * 1010 * link.max + + 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()), + HTTPStatus.OK, + ) + elif amount_received > max: + return ( + jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict()), + HTTPStatus.OK, + ) + + comment = request.args.get("comment") + if len(comment or "") > link.comment_chars: + return ( + jsonify( + LnurlErrorResponse( + reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}" + ).dict() + ), + HTTPStatus.OK, + ) + payment_hash, payment_request = create_invoice( wallet_id=link.wallet, - amount=link.amount, + amount=int(amount_received / 1000), memo=link.description, description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(), - extra={"tag": "lnurlp"}, + extra={"tag": "lnurlp", "link": link.id, "comment": comment}, ) - save_link_invoice(link_id, payment_request) - resp = LnurlPayActionResponse( pr=payment_request, success_action=link.success_action(payment_hash), diff --git a/lnbits/extensions/lnurlp/migrations.py b/lnbits/extensions/lnurlp/migrations.py index d9c61d360..f20cd6842 100644 --- a/lnbits/extensions/lnurlp/migrations.py +++ b/lnbits/extensions/lnurlp/migrations.py @@ -33,3 +33,16 @@ def m002_webhooks_and_success_actions(db): ); """ ) + + +def m003_min_max_comment_fiat(db): + """ + Support for min/max amounts, comments and fiat prices that get + converted automatically to satoshis based on some API. + """ + db.execute("ALTER TABLE pay_links ADD COLUMN currency TEXT;") # null = satoshis + db.execute("ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;") + db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;") + db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;") + db.execute("UPDATE pay_links SET max = min;") + db.execute("DROP TABLE invoices") diff --git a/lnbits/extensions/lnurlp/models.py b/lnbits/extensions/lnurlp/models.py index af542ded2..52a4c9924 100644 --- a/lnbits/extensions/lnurlp/models.py +++ b/lnbits/extensions/lnurlp/models.py @@ -12,12 +12,15 @@ class PayLink(NamedTuple): id: int wallet: str description: str - amount: int + min: int served_meta: int served_pr: int webhook_url: str success_text: str success_url: str + currency: str + comment_chars: int + max: int @classmethod def from_row(cls, row: Row) -> "PayLink": diff --git a/lnbits/extensions/lnurlp/static/js/index.js b/lnbits/extensions/lnurlp/static/js/index.js new file mode 100644 index 000000000..bbf5baf08 --- /dev/null +++ b/lnbits/extensions/lnurlp/static/js/index.js @@ -0,0 +1,207 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +var locationPath = [ + window.location.protocol, + '//', + window.location.host, + window.location.pathname +].join('') + +var mapPayLink = obj => { + obj._data = _.clone(obj) + obj.date = Quasar.utils.date.formatDate( + new Date(obj.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount) + obj.print_url = [locationPath, 'print/', obj.id].join('') + obj.pay_url = [locationPath, obj.id].join('') + return obj +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + fiatRates: {}, + checker: null, + payLinks: [], + payLinksTable: { + pagination: { + rowsPerPage: 10 + } + }, + formDialog: { + show: false, + fixedAmount: true, + data: {} + }, + qrCodeDialog: { + show: false, + data: null + } + } + }, + methods: { + getPayLinks() { + LNbits.api + .request( + 'GET', + '/lnurlp/api/v1/links?all_wallets', + this.g.user.wallets[0].inkey + ) + .then(response => { + this.payLinks = response.data.map(mapPayLink) + }) + .catch(err => { + clearInterval(this.checker) + LNbits.utils.notifyApiError(err) + }) + }, + closeFormDialog() {}, + openQrCodeDialog(linkId) { + var link = _.findWhere(this.payLinks, {id: linkId}) + if (link.currency) this.updateFiatRate(link.currency) + + this.qrCodeDialog.data = { + id: link.id, + amount: + (link.min === link.max ? link.min : `${link.min} - ${link.max}`) + + ' ' + + (link.currency || 'sat'), + currency: link.currency, + comments: link.comment_chars + ? `${link.comment_chars} characters` + : 'no', + webhook: link.webhook_url || 'nowhere', + success: + link.success_text || link.success_url + ? 'Display message "' + + link.success_text + + '"' + + (link.success_url ? ' and URL "' + link.success_url + '"' : '') + : 'do nothing', + lnurl: link.lnurl, + pay_url: link.pay_url, + print_url: link.print_url + } + this.qrCodeDialog.show = true + }, + openUpdateDialog(linkId) { + const link = _.findWhere(this.payLinks, {id: linkId}) + if (link.currency) this.updateFiatRate(link.currency) + + this.formDialog.data = _.clone(link._data) + this.formDialog.show = true + this.formDialog.fixedAmount = + this.formDialog.data.min === this.formDialog.data.max + }, + sendFormData() { + const wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + var data = _.omit(this.formDialog.data, 'wallet') + + if (this.formDialog.fixedAmount) data.max = data.min + if (data.currency === 'satoshis') data.currency = null + if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0 + + if (data.id) { + this.updatePayLink(wallet, data) + } else { + this.createPayLink(wallet, data) + } + }, + updatePayLink(wallet, data) { + let values = _.omit( + _.pick( + data, + 'description', + 'min', + 'max', + 'webhook_url', + 'success_text', + 'success_url', + 'comment_chars', + 'currency' + ), + (value, key) => + (key === 'webhook_url' || + key === 'success_text' || + key === 'success_url') && + (value === null || value === '') + ) + + LNbits.api + .request( + 'PUT', + '/lnurlp/api/v1/links/' + data.id, + wallet.adminkey, + values + ) + .then(response => { + this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id) + this.payLinks.push(mapPayLink(response.data)) + this.formDialog.show = false + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + createPayLink(wallet, data) { + LNbits.api + .request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data) + .then(response => { + this.payLinks.push(mapPayLink(response.data)) + this.formDialog.show = false + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + deletePayLink: linkId => { + var link = _.findWhere(this.payLinks, {id: linkId}) + + LNbits.utils + .confirmDialog('Are you sure you want to delete this pay link?') + .onOk(() => { + LNbits.api + .request( + 'DELETE', + '/lnurlp/api/v1/links/' + linkId, + _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey + ) + .then(response => { + this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + }, + updateFiatRate(currency) { + LNbits.api + .request('GET', '/lnurlp/api/v1/rate/' + currency, null) + .then(response => { + let rates = _.clone(this.fiatRates) + rates[currency] = response.data.rate + this.fiatRates = rates + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + } + }, + created() { + if (this.g.user.wallets.length) { + var getPayLinks = this.getPayLinks + getPayLinks() + this.checker = setInterval(() => { + getPayLinks() + }, 20000) + } + } +}) diff --git a/lnbits/extensions/lnurlp/tasks.py b/lnbits/extensions/lnurlp/tasks.py index 27bb91570..3be3a98ed 100644 --- a/lnbits/extensions/lnurlp/tasks.py +++ b/lnbits/extensions/lnurlp/tasks.py @@ -4,7 +4,7 @@ import httpx from lnbits.core.models import Payment from lnbits.tasks import run_on_pseudo_request, register_invoice_listener -from .crud import get_pay_link_by_invoice, mark_webhook_sent +from .crud import mark_webhook_sent, get_pay_link async def register_listeners(): @@ -19,25 +19,29 @@ async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): async def on_invoice_paid(payment: Payment) -> None: - islnurlp = "lnurlp" == payment.extra.get("tag") - if islnurlp: - pay_link = get_pay_link_by_invoice(payment.payment_hash) - if not pay_link: - # no pay_link or this webhook has already been sent - return - if pay_link.webhook_url: - async with httpx.AsyncClient() as client: - try: - r = await client.post( - pay_link.webhook_url, - json={ - "payment_hash": payment.payment_hash, - "payment_request": payment.bolt11, - "amount": payment.amount, - "lnurlp": pay_link.id, - }, - timeout=40, - ) - mark_webhook_sent(payment.payment_hash, r.status_code) - except (httpx.ConnectError, httpx.RequestError): - mark_webhook_sent(payment.payment_hash, -1) + if "lnurlp" != payment.extra.get("tag"): + # not an lnurlp invoice + return + + if payment.extra.get("wh_status"): + # this webhook has already been sent + return + + pay_link = get_pay_link(payment.extra.get("link", -1)) + if pay_link and pay_link.webhook_url: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + pay_link.webhook_url, + json={ + "payment_hash": payment.payment_hash, + "payment_request": payment.bolt11, + "amount": payment.amount, + "comment": payment.extra.get("comment"), + "lnurlp": pay_link.id, + }, + timeout=40, + ) + mark_webhook_sent(payment, r.status_code) + except (httpx.ConnectError, httpx.RequestError): + mark_webhook_sent(payment, -1) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html index f7bb76a2f..5d0967162 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -16,25 +16,22 @@
Pay links
-
- Export to CSV -
{% raw %} @@ -60,8 +57,39 @@ @click="openQrCodeDialog(props.row.id)" > - - {{ col.value }} + {{ props.row.description }} + + + {{ props.row.min }} + + {{ props.row.min }} - {{ props.row.max }} + + {{ props.row.currency || 'sat' }} + + + Webhook to {{ props.row.webhook_url}} + + + + On success, show message '{{ props.row.success_text }}' + and URL '{{ props.row.success_url }}' + + + + + {{ props.row.comment_chars }}-char comment allowed + + +
+ + +
+
+
+ +
+
+ +
+

ID: {{ qrCodeDialog.data.id }}
- Amount: {{ qrCodeDialog.data.amount }} sat
- Webhook: {{ qrCodeDialog.data.webhook_url }}
- Success Message: {{ qrCodeDialog.data.success_text + Amount: {{ qrCodeDialog.data.amount }}
+ {{ qrCodeDialog.data.currency }} price: {{ + fiatRates[qrCodeDialog.data.currency] ? + fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}
+ Accepts comments: {{ qrCodeDialog.data.comments }}
+ Dispatches webhook to: {{ qrCodeDialog.data.webhook }}
- Success URL: {{ qrCodeDialog.data.success_url }}
+ On success: {{ qrCodeDialog.data.success }}

{% endraw %}
@@ -220,7 +293,6 @@ >Shareable link
{% endblock %} {% block scripts %} {{ window_vars(user) }} - + {% endblock %} diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py index 9dac7b395..c211ba061 100644 --- a/lnbits/extensions/lnurlp/views_api.py +++ b/lnbits/extensions/lnurlp/views_api.py @@ -1,11 +1,11 @@ from quart import g, jsonify, request from http import HTTPStatus -from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl +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.extensions.lnurlp import lnurlp_ext +from lnbits.extensions.lnurlp import lnurlp_ext # type: ignore from .crud import ( create_pay_link, get_pay_link, @@ -13,6 +13,7 @@ from .crud import ( update_pay_link, delete_pay_link, ) +from .helpers import get_fiat_rate @lnurlp_ext.route("/api/v1/links", methods=["GET"]) @@ -55,13 +56,24 @@ async def api_link_retrieve(link_id): @api_validate_post_request( schema={ "description": {"type": "string", "empty": False, "required": True}, - "amount": {"type": "integer", "min": 1, "required": True}, + "min": {"type": "number", "min": 0.01, "required": True}, + "max": {"type": "number", "min": 0.01, "required": True}, + "currency": {"type": "string", "allowed": ["USD"], "nullable": True, "required": False}, + "comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800}, "webhook_url": {"type": "string", "required": False}, "success_text": {"type": "string", "required": False}, "success_url": {"type": "string", "required": False}, } ) async def api_link_create_or_update(link_id=None): + if g.data["min"] > g.data["max"]: + return jsonify({"message": "Min is greater than max."}), HTTPStatus.BAD_REQUEST + + if g.data.get("currency") == None and ( + round(g.data["min"]) != g.data["min"] or round(g.data["max"]) != g.data["max"] + ): + return jsonify({"message": "Must use full satoshis."}), HTTPStatus.BAD_REQUEST + if link_id: link = get_pay_link(link_id) @@ -92,3 +104,13 @@ async def api_link_delete(link_id): delete_pay_link(link_id) return "", HTTPStatus.NO_CONTENT + + +@lnurlp_ext.route("/api/v1/rate/", methods=["GET"]) +async def api_check_fiat_rate(currency): + try: + rate = await get_fiat_rate(currency) + except AssertionError: + rate = None + + return jsonify({"rate": rate}), HTTPStatus.OK diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index 8db4eed3a..f26712d3b 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }} - + {% endblock %} {% block page %}