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 %}
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+
+ {% 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")