diff --git a/lnbits/extensions/lnurlp/README.md b/lnbits/extensions/lnurlp/README.md new file mode 100644 index 0000000..34a4bc0 --- /dev/null +++ b/lnbits/extensions/lnurlp/README.md @@ -0,0 +1 @@ +# LNURLp diff --git a/lnbits/extensions/lnurlp/__init__.py b/lnbits/extensions/lnurlp/__init__.py new file mode 100644 index 0000000..5b41e06 --- /dev/null +++ b/lnbits/extensions/lnurlp/__init__.py @@ -0,0 +1,8 @@ +from flask import Blueprint + + +lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", template_folder="templates") + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/lnurlp/config.json b/lnbits/extensions/lnurlp/config.json new file mode 100644 index 0000000..294afe7 --- /dev/null +++ b/lnbits/extensions/lnurlp/config.json @@ -0,0 +1,10 @@ +{ + "name": "LNURLp", + "short_description": "Make reusable LNURL pay links", + "icon": "receipt", + "contributors": [ + "arcbtc", + "eillarra", + "fiatjaf" + ] +} diff --git a/lnbits/extensions/lnurlp/crud.py b/lnbits/extensions/lnurlp/crud.py new file mode 100644 index 0000000..b53ac1b --- /dev/null +++ b/lnbits/extensions/lnurlp/crud.py @@ -0,0 +1,74 @@ +from typing import List, Optional, Union + +from lnbits.db import open_ext_db + +from .models import PayLink + + +def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink: + with open_ext_db("lnurlp") as db: + with db.cursor() as c: + c.execute( + """ + INSERT INTO pay_links ( + wallet, + description, + amount, + served_meta, + served_pr + ) + VALUES (?, ?, ?, 0, 0) + """, + (wallet_id, description, amount), + ) + return get_pay_link(c.lastrowid) + + +def get_pay_link(link_id: str) -> Optional[PayLink]: + with open_ext_db("lnurlp") as db: + row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) + + return PayLink.from_row(row) if row else None + + +def get_pay_link_by_hash(unique_hash: str) -> Optional[PayLink]: + with open_ext_db("lnurlp") as db: + row = db.fetchone("SELECT * FROM pay_links WHERE unique_hash = ?", (unique_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] + + with open_ext_db("lnurlp") as db: + q = ",".join(["?"] * len(wallet_ids)) + rows = db.fetchall(f"SELECT * FROM pay_links WHERE wallet IN ({q})", (*wallet_ids,)) + + return [PayLink.from_row(row) for row in rows] + + +def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + with open_ext_db("lnurlp") as db: + db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)) + row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) + + return PayLink.from_row(row) if row else None + + +def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: + q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) + + with open_ext_db("lnurlp") as db: + db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)) + row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) + + return PayLink.from_row(row) if row else None + + +def delete_pay_link(link_id: str) -> None: + with open_ext_db("lnurlp") as db: + db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,)) diff --git a/lnbits/extensions/lnurlp/migrations.py b/lnbits/extensions/lnurlp/migrations.py new file mode 100644 index 0000000..b1fe152 --- /dev/null +++ b/lnbits/extensions/lnurlp/migrations.py @@ -0,0 +1,24 @@ +from lnbits.db import open_ext_db + + +def m001_initial(db): + """ + Initial pay table. + """ + db.execute( + """ + CREATE TABLE IF NOT EXISTS pay_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet TEXT NOT NULL, + description TEXT NOT NULL, + amount INTEGER NOT NULL, + served_meta INTEGER NOT NULL, + served_pr INTEGER NOT NULL + ); + """ + ) + + +def migrate(): + with open_ext_db("lnurlp") as db: + m001_initial(db) diff --git a/lnbits/extensions/lnurlp/models.py b/lnbits/extensions/lnurlp/models.py new file mode 100644 index 0000000..048b02c --- /dev/null +++ b/lnbits/extensions/lnurlp/models.py @@ -0,0 +1,32 @@ +import json +from flask import url_for +from lnurl import Lnurl, encode as lnurl_encode +from lnurl.types import LnurlPayMetadata +from sqlite3 import Row +from typing import NamedTuple + +from lnbits.settings import FORCE_HTTPS + + +class PayLink(NamedTuple): + id: str + wallet: str + description: str + amount: int + served_meta: int + served_pr: int + + @classmethod + def from_row(cls, row: Row) -> "PayLink": + data = dict(row) + return cls(**data) + + @property + def lnurl(self) -> Lnurl: + scheme = "https" if FORCE_HTTPS else None + url = url_for("lnurlp.api_lnurl_response", link_id=self.id, _external=True, _scheme=scheme) + return lnurl_encode(url) + + @property + def lnurlpay_metadata(self) -> LnurlPayMetadata: + return LnurlPayMetadata(json.dumps([["text/plain", self.description]])) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html new file mode 100644 index 0000000..bf6176f --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html @@ -0,0 +1,130 @@ + + + + + GET /pay/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<pay_link_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}pay/api/v1/links -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /pay/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X GET {{ request.url_root }}pay/api/v1/links/<pay_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /pay/api/v1/links +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"description": <string> "amount": <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X POST {{ request.url_root }}pay/api/v1/links -d + '{"description": <string>, "amount": <integer>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /pay/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"description": <string>, "amount": <integer>} +
+ Returns 200 OK (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X PUT {{ request.url_root }}pay/api/v1/links/<pay_id> -d + '{"description": <string>, "amount": <integer>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /pay/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root }}pay/api/v1/links/<pay_id> + -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html b/lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html new file mode 100644 index 0000000..efb9a48 --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html @@ -0,0 +1,29 @@ + + + +

+ WARNING: LNURL must be used over https or TOR
+ LNURL is a range of lightning-network standards that allow us to use + lightning-network differently. An LNURL withdraw is the permission for + someone to pull a certain amount of funds from a lightning wallet. In + this extension time is also added - an amount can be withdraw over a + period of time. A typical use case for an LNURL withdraw is a faucet, + although it is a very powerful technology, with much further reaching + implications. For example, an LNURL withdraw could be minted to pay for + a subscription service. +

+

+ Exploring LNURL and finding use cases, is really helping inform + lightning protocol development, rather than the protocol dictating how + lightning-network should be engaged with. +

+ Check + Awesome LNURL + for further information. +
+
+
diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/display.html b/lnbits/extensions/lnurlp/templates/lnurlp/display.html new file mode 100644 index 0000000..11af36a --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/display.html @@ -0,0 +1,54 @@ +{% extends "public.html" %} {% block page %} +
+
+ + + +
+ Copy LNURL +
+
+
+
+
+ + +
+ LNbits LNURL-pay link +
+

+ Use a LNURL compatible bitcoin wallet to claim the sats. +

+
+ + + + {% include "lnurlp/_lnurl.html" %} + + +
+
+
+{% endblock %} {% block scripts %} + + +{% endblock %} diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html new file mode 100644 index 0000000..f1ea5ae --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -0,0 +1,402 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New pay link + + + + + +
+
+
Pay links
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ LNbits LNURL-pay extension +
+
+ + + + {% include "lnurlp/_api_docs.html" %} + + {% include "lnurlp/_lnurl.html" %} + + +
+
+ + + + + + + + +
+ Update pay link + Create pay link + Cancel +
+
+
+
+ + + + {% raw %} + + + +

+ ID: {{ qrCodeDialog.data.id }}
+ Amount: {{ qrCodeDialog.data.amount }} sat
+

+ {% endraw %} +
+ Copy LNURL + Shareable link + + Close +
+
+
+
+{% endblock %} +{% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/print_qr.html b/lnbits/extensions/lnurlp/templates/lnurlp/print_qr.html new file mode 100644 index 0000000..7cbac84 --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/print_qr.html @@ -0,0 +1,28 @@ +{% extends "print.html" %} {% block page %} +
+
+ +
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + + +{% endblock %} diff --git a/lnbits/extensions/lnurlp/views.py b/lnbits/extensions/lnurlp/views.py new file mode 100644 index 0000000..a0889a5 --- /dev/null +++ b/lnbits/extensions/lnurlp/views.py @@ -0,0 +1,28 @@ +from flask import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from lnbits.extensions.lnurlp import lnurlp_ext +from .crud import get_pay_link + + +@lnurlp_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +def index(): + return render_template("lnurlp/index.html", user=g.user) + + +@lnurlp_ext.route("/") +def display(link_id): + link = get_pay_link(link_id) or abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.") + + return render_template("lnurlp/display.html", link=link) + + +@lnurlp_ext.route("/print/") +def print_qr(link_id): + link = get_pay_link(link_id) or abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.") + + return render_template("lnurlp/print_qr.html", link=link) diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py new file mode 100644 index 0000000..4b4a7ba --- /dev/null +++ b/lnbits/extensions/lnurlp/views_api.py @@ -0,0 +1,125 @@ +from flask import g, jsonify, request, url_for +from http import HTTPStatus +from lnurl import LnurlPayResponse, LnurlPayActionResponse +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl + +from lnbits.core.crud import get_user +from lnbits.core.services import create_invoice +from lnbits.decorators import api_check_wallet_key, api_validate_post_request +from lnbits.settings import FORCE_HTTPS + +from lnbits.extensions.lnurlp import lnurlp_ext +from .crud import ( + create_pay_link, + get_pay_link, + get_pay_links, + update_pay_link, + increment_pay_link, + delete_pay_link, +) + + +@lnurlp_ext.route("/api/v1/links", methods=["GET"]) +@api_check_wallet_key("invoice") +def api_links(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = get_user(g.wallet.user).wallet_ids + + try: + return ( + jsonify([{**link._asdict(), **{"lnurl": link.lnurl}} for link in get_pay_links(wallet_ids)]), + HTTPStatus.OK, + ) + except LnurlInvalidUrl: + return ( + jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}), + HTTPStatus.UPGRADE_REQUIRED, + ) + + +@lnurlp_ext.route("/api/v1/links/", methods=["GET"]) +@api_check_wallet_key("invoice") +def api_link_retrieve(link_id): + link = get_pay_link(link_id) + + if not link: + return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND + + if link.wallet != g.wallet.id: + return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN + + return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK + + +@lnurlp_ext.route("/api/v1/links", methods=["POST"]) +@lnurlp_ext.route("/api/v1/links/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "description": {"type": "string", "empty": False, "required": True}, + "amount": {"type": "integer", "min": 1, "required": True}, + } +) +def api_link_create_or_update(link_id=None): + if link_id: + link = get_pay_link(link_id) + + if not link: + return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND + + if link.wallet != g.wallet.id: + return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN + + link = update_pay_link(link_id, **g.data) + else: + link = create_pay_link(wallet_id=g.wallet.id, **g.data) + + return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK if link_id else HTTPStatus.CREATED + + +@lnurlp_ext.route("/api/v1/links/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +def api_link_delete(link_id): + link = get_pay_link(link_id) + + if not link: + return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND + + if link.wallet != g.wallet.id: + return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN + + delete_pay_link(link_id) + + return "", HTTPStatus.NO_CONTENT + + +@lnurlp_ext.route("/api/v1/lnurl/", methods=["GET"]) +def api_lnurl_response(link_id): + link = increment_pay_link(link_id, served_meta=1) + if not link: + return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK + + scheme = "https" if FORCE_HTTPS else None + url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True, _scheme=scheme) + + resp = LnurlPayResponse( + callback=url, min_sendable=link.amount * 1000, max_sendable=link.amount * 1000, metadata=link.lnurlpay_metadata, + ) + + return jsonify(resp.dict()), HTTPStatus.OK + + +@lnurlp_ext.route("/api/v1/lnurl/cb/", methods=["GET"]) +def api_lnurl_callback(link_id): + link = increment_pay_link(link_id, served_pr=1) + if not link: + return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK + + _, payment_request = create_invoice( + wallet_id=link.wallet, amount=link.amount, memo=None, description_hash=link.lnurlpay_metadata.encode("utf-8"), + ) + resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[]) + + return jsonify(resp.dict()), HTTPStatus.OK diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index 859b702..553661e 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -26,7 +26,7 @@ class LNPayWallet(Wallet): "description_hash": base64.b64encode(description_hash).decode("ascii"), }, ) - ok, checking_id, payment_request, error_message = r.status_code == 201, None, None, None + ok, checking_id, payment_request, error_message = r.status_code == 201, None, None, r.text if ok: data = r.json()