diff --git a/docs/guide/wallets.md b/docs/guide/wallets.md index 434a3ab5..3eb5ea8c 100644 --- a/docs/guide/wallets.md +++ b/docs/guide/wallets.md @@ -21,6 +21,11 @@ Using this wallet requires the installation of the `pylightning` Python package. - `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet** - `CLIGHTNING_RPC`: /file/path/lightning-rpc +### Spark (c-lightning) + +- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet** +- `SPARK_URL`: http://10.147.17.230:9737/rpc +- `SPARK_TOKEN`: secret_access_key ### LND (gRPC) diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index bc3a7971..81154be4 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -2,7 +2,6 @@ import bitstring import re -from binascii import hexlify from bech32 import bech32_decode, CHARSET @@ -51,9 +50,9 @@ def decode(pr: str) -> Invoice: if tag == "d": invoice.description = trim_to_bytes(tagdata).decode("utf-8") elif tag == "h" and data_length == 52: - invoice.description = hexlify(trim_to_bytes(tagdata)).decode("ascii") + invoice.description = trim_to_bytes(tagdata).hex() elif tag == "p" and data_length == 52: - invoice.payment_hash = hexlify(trim_to_bytes(tagdata)).decode("ascii") + invoice.payment_hash = trim_to_bytes(tagdata).hex() return invoice diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index a06e045b..94ead322 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -115,7 +115,7 @@ def get_wallet(wallet_id: str) -> Optional[Wallet]: def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]: with open_db() as db: row = db.fetchone( - f""" + """ SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat FROM wallets WHERE adminkey = ? OR inkey = ? diff --git a/lnbits/core/services.py b/lnbits/core/services.py index d5924c04..f36ee546 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -1,15 +1,18 @@ from typing import Optional, Tuple -from lnbits.bolt11 import decode as bolt11_decode # type: ignore +from lnbits.bolt11 import decode as bolt11_decode # type: ignore from lnbits.helpers import urlsafe_short_hash from lnbits.settings import WALLET from .crud import get_wallet, create_payment, delete_payment -def create_invoice(*, wallet_id: str, amount: int, memo: str) -> Tuple[str, str]: +def create_invoice(*, wallet_id: str, amount: int, memo: str, description_hash: bytes) -> Tuple[str, str]: + try: - ok, checking_id, payment_request, error_message = WALLET.create_invoice(amount=amount, memo=memo) + ok, checking_id, payment_request, error_message = WALLET.create_invoice( + amount=amount, memo=memo, description_hash=description_hash + ) except Exception as e: ok, error_message = False, str(e) @@ -35,11 +38,7 @@ def pay_invoice(*, wallet_id: str, bolt11: str, max_sat: Optional[int] = None) - fee_reserve = max(1000, int(invoice.amount_msat * 0.01)) create_payment( - wallet_id=wallet_id, - checking_id=temp_id, - amount=-invoice.amount_msat, - fee=-fee_reserve, - memo=temp_id, + wallet_id=wallet_id, checking_id=temp_id, amount=-invoice.amount_msat, fee=-fee_reserve, memo=temp_id, ) wallet = get_wallet(wallet_id) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index b6c47b0b..c66d873d 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -1,5 +1,6 @@ from flask import g, jsonify, request from http import HTTPStatus +from binascii import unhexlify from lnbits.core import core_app from lnbits.decorators import api_check_wallet_key, api_validate_post_request @@ -27,13 +28,21 @@ def api_payments(): @api_validate_post_request( schema={ "amount": {"type": "integer", "min": 1, "required": True}, - "memo": {"type": "string", "empty": False, "required": True}, + "memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"}, + "description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"}, } ) def api_payments_create_invoice(): + if "description_hash" in g.data: + description_hash = unhexlify(g.data["description_hash"]) + memo = "" + else: + description_hash = b"" + memo = g.data["memo"] + try: checking_id, payment_request = create_invoice( - wallet_id=g.wallet.id, amount=g.data["amount"], memo=g.data["memo"] + wallet_id=g.wallet.id, amount=g.data["amount"], memo=memo, description_hash=description_hash ) except Exception as e: return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/lnbits/decorators.py b/lnbits/decorators.py index ef1ef66d..9298d1e7 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -36,7 +36,7 @@ def api_validate_post_request(*, schema: dict): return jsonify({"message": "Content-Type must be `application/json`."}), HTTPStatus.BAD_REQUEST v = Validator(schema) - g.data = {key: (request.json[key] if key in request.json else None) for key in schema.keys()} + g.data = {key: request.json[key] for key in schema.keys() if key in request.json} if not v.validate(g.data): return jsonify({"message": f"Errors in request data: {v.errors}"}), HTTPStatus.BAD_REQUEST @@ -56,7 +56,7 @@ def check_user_exists(param: str = "usr"): allowed_users = getenv("LNBITS_ALLOWED_USERS", "all") if allowed_users != "all" and g.user.id not in allowed_users.split(","): - abort(HTTPStatus.UNAUTHORIZED, f"User not authorized.") + abort(HTTPStatus.UNAUTHORIZED, "User not authorized.") return view(**kwargs) diff --git a/lnbits/extensions/lnurlp/README.md b/lnbits/extensions/lnurlp/README.md new file mode 100644 index 00000000..34a4bc0b --- /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 00000000..5b41e06a --- /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 00000000..294afe73 --- /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 00000000..b53ac1bd --- /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 00000000..b1fe1524 --- /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 00000000..048b02c2 --- /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 00000000..bf6176f6 --- /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 00000000..da46d9c4 --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html @@ -0,0 +1,28 @@ + + + +

+ 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-pay is a link that wallets use + to fetch an invoice from a server on-demand. The link or QR code is + fixed, but each time it is read by a compatible wallet a new QR code is + issued by the service. It can be used to activate machines without them + having to maintain an electronic screen to generate and show invoices + locally, or to sell any predefined good or service automatically. +

+

+ 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 00000000..11af36ac --- /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 00000000..c48c1f13 --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -0,0 +1,401 @@ +{% 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 00000000..cb3e0062 --- /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 00000000..a0889a50 --- /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 00000000..e62ddd74 --- /dev/null +++ b/lnbits/extensions/lnurlp/views_api.py @@ -0,0 +1,129 @@ +import hashlib +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=link.description, + description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(), + ) + resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[]) + + return jsonify(resp.dict()), HTTPStatus.OK diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index bfe8f218..263f5e03 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -7,3 +7,4 @@ from .opennode import OpenNodeWallet from .lnpay import LNPayWallet from .lnbits import LNbitsWallet from .lndrest import LndRestWallet +from .spark import SparkWallet diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index ced00139..2547d581 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -26,7 +26,7 @@ class PaymentStatus(NamedTuple): class Wallet(ABC): @abstractmethod - def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: + def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse: pass @abstractmethod @@ -40,3 +40,7 @@ class Wallet(ABC): @abstractmethod def get_payment_status(self, checking_id: str) -> PaymentStatus: pass + + +class Unsupported(Exception): + pass diff --git a/lnbits/wallets/clightning.py b/lnbits/wallets/clightning.py index ae4486d2..a0d750bf 100644 --- a/lnbits/wallets/clightning.py +++ b/lnbits/wallets/clightning.py @@ -7,39 +7,47 @@ import random from os import getenv -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported class CLightningWallet(Wallet): - def __init__(self): if LightningRpc is None: # pragma: nocover raise ImportError("The `pylightning` library must be installed to use `CLightningWallet`.") self.l1 = LightningRpc(getenv("CLIGHTNING_RPC")) - def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: + def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse: + if description_hash: + raise Unsupported("description_hash") + label = "lbl{}".format(random.random()) - r = self.l1.invoice(amount*1000, label, memo, exposeprivatechannels=True) + r = self.l1.invoice(amount * 1000, label, memo, exposeprivatechannels=True) ok, checking_id, payment_request, error_message = True, r["payment_hash"], r["bolt11"], None return InvoiceResponse(ok, checking_id, payment_request, error_message) def pay_invoice(self, bolt11: str) -> PaymentResponse: r = self.l1.pay(bolt11) - ok, checking_id, fee_msat, error_message = True, None, 0, None + ok, checking_id, fee_msat, error_message = True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None return PaymentResponse(ok, checking_id, fee_msat, error_message) def get_invoice_status(self, checking_id: str) -> PaymentStatus: r = self.l1.listinvoices(checking_id) - if r['invoices'][0]['status'] == 'unpaid': + if not r["invoices"]: return PaymentStatus(False) - return PaymentStatus(True) + if r["invoices"][0]["label"] == checking_id: + return PaymentStatus(r["pays"][0]["status"] == "paid") + raise KeyError("supplied an invalid checking_id") def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = self.l1.listsendpays(checking_id) - if not r.ok: + r = self.l1.listpays(payment_hash=checking_id) + if not r["pays"]: + return PaymentStatus(False) + if r["pays"][0]["payment_hash"] == checking_id: + status = r["pays"][0]["status"] + if status == "complete": + return PaymentStatus(True) + elif status == "failed": + return PaymentStatus(False) return PaymentStatus(None) - payments = [p for p in r.json()["payments"] if p["payment_hash"] == checking_id] - payment = payments[0] if payments else None - statuses = {"UNKNOWN": None, "IN_FLIGHT": None, "SUCCEEDED": True, "FAILED": False} - return PaymentStatus(statuses[payment["status"]] if payment else None) + raise KeyError("supplied an invalid checking_id") diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py index c3e0a0dc..4c3bc016 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -12,11 +12,11 @@ class LNbitsWallet(Wallet): self.auth_admin = {"X-Api-Key": getenv("LNBITS_ADMIN_KEY")} self.auth_invoice = {"X-Api-Key": getenv("LNBITS_INVOICE_KEY")} - def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: + def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse: r = post( url=f"{self.endpoint}/api/v1/payments", headers=self.auth_invoice, - json={"out": False, "amount": amount, "memo": memo} + json={"out": False, "amount": amount, "memo": memo, "description_hash": description_hash.hex(),}, ) ok, checking_id, payment_request, error_message = r.ok, None, None, None @@ -29,11 +29,7 @@ class LNbitsWallet(Wallet): return InvoiceResponse(ok, checking_id, payment_request, error_message) def pay_invoice(self, bolt11: str) -> PaymentResponse: - r = post( - url=f"{self.endpoint}/api/v1/payments", - headers=self.auth_admin, - json={"out": True, "bolt11": bolt11} - ) + r = post(url=f"{self.endpoint}/api/v1/payments", headers=self.auth_admin, json={"out": True, "bolt11": bolt11}) ok, checking_id, fee_msat, error_message = True, None, 0, None if r.ok: @@ -50,7 +46,7 @@ class LNbitsWallet(Wallet): if not r.ok: return PaymentStatus(None) - return PaymentStatus(r.json()['paid']) + return PaymentStatus(r.json()["paid"]) def get_payment_status(self, checking_id: str) -> PaymentStatus: r = get(url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.auth_invoice) @@ -58,4 +54,4 @@ class LNbitsWallet(Wallet): if not r.ok: return PaymentStatus(None) - return PaymentStatus(r.json()['paid']) + return PaymentStatus(r.json()["paid"]) diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py index 62578f21..63df1762 100644 --- a/lnbits/wallets/lndgrpc.py +++ b/lnbits/wallets/lndgrpc.py @@ -23,7 +23,7 @@ class LndWallet(Wallet): self.auth_read = getenv("LND_READ_MACAROON") self.auth_cert = getenv("LND_CERT") - def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: + def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse: lnd_rpc = lnd_grpc.Client( lnd_dir=None, macaroon_path=self.auth_invoice, @@ -33,7 +33,13 @@ class LndWallet(Wallet): grpc_port=self.port, ) - lndResponse = lnd_rpc.add_invoice(memo=memo, value=amount, expiry=600, private=True) + lndResponse = lnd_rpc.add_invoice( + memo=memo, + description_hash=base64.b64encode(description_hash).decode("ascii"), + value=amount, + expiry=600, + private=True, + ) decoded_hash = base64.b64encode(lndResponse.r_hash).decode("utf-8").replace("/", "_") print(lndResponse.r_hash) ok, checking_id, payment_request, error_message = True, decoded_hash, str(lndResponse.payment_request), None diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index f9c98a2e..1e7187b3 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -1,5 +1,4 @@ from os import getenv -import os import base64 from requests import get, post from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet @@ -18,13 +17,17 @@ class LndRestWallet(Wallet): self.auth_read = {"Grpc-Metadata-macaroon": getenv("LND_REST_READ_MACAROON")} self.auth_cert = getenv("LND_REST_CERT") - - def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: - + def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse: r = post( url=f"{self.endpoint}/v1/invoices", - headers=self.auth_invoice, verify=self.auth_cert, - json={"value": amount, "memo": memo, "private": True}, + headers=self.auth_invoice, + verify=self.auth_cert, + json={ + "value": amount, + "memo": memo, + "description_hash": base64.b64encode(description_hash).decode("ascii"), + "private": True, + }, ) print(self.auth_invoice) @@ -37,17 +40,19 @@ class LndRestWallet(Wallet): r = get(url=f"{self.endpoint}/v1/payreq/{payment_request}", headers=self.auth_read, verify=self.auth_cert,) print(r) if r.ok: - checking_id = r.json()["payment_hash"].replace("/","_") + checking_id = r.json()["payment_hash"].replace("/", "_") print(checking_id) error_message = None ok = True return InvoiceResponse(ok, checking_id, payment_request, error_message) - def pay_invoice(self, bolt11: str) -> PaymentResponse: r = post( - url=f"{self.endpoint}/v1/channels/transactions", headers=self.auth_admin, verify=self.auth_cert, json={"payment_request": bolt11} + url=f"{self.endpoint}/v1/channels/transactions", + headers=self.auth_admin, + verify=self.auth_cert, + json={"payment_request": bolt11}, ) ok, checking_id, fee_msat, error_message = r.ok, None, 0, None r = get(url=f"{self.endpoint}/v1/payreq/{bolt11}", headers=self.auth_admin, verify=self.auth_cert,) @@ -59,9 +64,8 @@ class LndRestWallet(Wallet): return PaymentResponse(ok, checking_id, fee_msat, error_message) - def get_invoice_status(self, checking_id: str) -> PaymentStatus: - checking_id = checking_id.replace("_","/") + checking_id = checking_id.replace("_", "/") print(checking_id) r = get(url=f"{self.endpoint}/v1/invoice/{checking_id}", headers=self.auth_invoice, verify=self.auth_cert,) print(r.json()["settled"]) @@ -71,7 +75,12 @@ class LndRestWallet(Wallet): return PaymentStatus(r.json()["settled"]) def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = get(url=f"{self.endpoint}/v1/payments", headers=self.auth_admin, verify=self.auth_cert, params={"include_incomplete": "True", "max_payments": "20"}) + r = get( + url=f"{self.endpoint}/v1/payments", + headers=self.auth_admin, + verify=self.auth_cert, + params={"include_incomplete": "True", "max_payments": "20"}, + ) if not r.ok: return PaymentStatus(None) diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index 4b3aced2..e77817ec 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -15,13 +15,13 @@ class LNPayWallet(Wallet): self.auth_read = getenv("LNPAY_READ_KEY") self.auth_api = {"X-Api-Key": getenv("LNPAY_API_KEY")} - def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: + def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse: r = post( url=f"{self.endpoint}/user/wallet/{self.auth_invoice}/invoice", headers=self.auth_api, - json={"num_satoshis": f"{amount}", "memo": memo}, + json={"num_satoshis": f"{amount}", "memo": memo, "description_hash": description_hash.hex(),}, ) - 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() diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py index 57bcdbf4..0f73fa15 100644 --- a/lnbits/wallets/lntxbot.py +++ b/lnbits/wallets/lntxbot.py @@ -13,8 +13,12 @@ class LntxbotWallet(Wallet): self.auth_admin = {"Authorization": f"Basic {getenv('LNTXBOT_ADMIN_KEY')}"} self.auth_invoice = {"Authorization": f"Basic {getenv('LNTXBOT_INVOICE_KEY')}"} - def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: - r = post(url=f"{self.endpoint}/addinvoice", headers=self.auth_invoice, json={"amt": str(amount), "memo": memo}) + def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse: + r = post( + url=f"{self.endpoint}/addinvoice", + headers=self.auth_invoice, + json={"amt": str(amount), "memo": memo, "description_hash": description_hash.hex()}, + ) ok, checking_id, payment_request, error_message = r.ok, None, None, None if r.ok: diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py index 679db779..8a8f096e 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -1,7 +1,7 @@ from os import getenv from requests import get, post -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported class OpenNodeWallet(Wallet): @@ -13,7 +13,10 @@ class OpenNodeWallet(Wallet): self.auth_admin = {"Authorization": getenv("OPENNODE_ADMIN_KEY")} self.auth_invoice = {"Authorization": getenv("OPENNODE_INVOICE_KEY")} - def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: + def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse: + if description_hash: + raise Unsupported("description_hash") + r = post( url=f"{self.endpoint}/v1/charges", headers=self.auth_invoice, diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py new file mode 100644 index 00000000..f647e897 --- /dev/null +++ b/lnbits/wallets/spark.py @@ -0,0 +1,86 @@ +import random +import requests +from os import getenv + +from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet + + +class SparkError(Exception): + pass + + +class UnknownError(Exception): + pass + + +class SparkWallet(Wallet): + def __init__(self): + self.url = getenv("SPARK_URL") + self.token = getenv("SPARK_TOKEN") + + def __getattr__(self, key): + def call(*args, **kwargs): + if args and kwargs: + raise TypeError(f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}") + elif args: + params = args + elif kwargs: + params = kwargs + + r = requests.post(self.url, headers={"X-Access": self.token}, json={"method": key, "params": params}) + try: + data = r.json() + except: + raise UnknownError(r.text) + if not r.ok: + raise SparkError(data["message"]) + return data + + return call + + def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse: + label = "lbs{}".format(random.random()) + checking_id = label + + try: + if description_hash: + r = self.invoicewithdescriptionhash( + msatoshi=amount * 1000, label=label, description_hash=description_hash.hex(), + ) + else: + r = self.invoice(msatoshi=amount * 1000, label=label, description=memo, exposeprivatechannels=True) + ok, payment_request, error_message = True, r["bolt11"], "" + except (SparkError, UnknownError) as e: + ok, payment_request, error_message = False, None, str(e) + + return InvoiceResponse(ok, checking_id, payment_request, error_message) + + def pay_invoice(self, bolt11: str) -> PaymentResponse: + try: + r = self.pay(bolt11) + ok, checking_id, fee_msat, error_message = True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None + except (SparkError, UnknownError) as e: + ok, checking_id, fee_msat, error_message = False, None, None, str(e) + + return PaymentResponse(ok, checking_id, fee_msat, error_message) + + def get_invoice_status(self, checking_id: str) -> PaymentStatus: + r = self.listinvoices(label=checking_id) + if not r or not r.get("invoices"): + return PaymentStatus(None) + if r["invoices"][0]["status"] == "unpaid": + return PaymentStatus(False) + return PaymentStatus(True) + + def get_payment_status(self, checking_id: str) -> PaymentStatus: + r = self.listpays(payment_hash=checking_id) + if not r["pays"]: + return PaymentStatus(False) + if r["pays"][0]["payment_hash"] == checking_id: + status = r["pays"][0]["status"] + if status == "complete": + return PaymentStatus(True) + elif status == "failed": + return PaymentStatus(False) + return PaymentStatus(None) + raise KeyError("supplied an invalid checking_id")