diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index 6ab684ab7..e08965b63 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -40,13 +40,13 @@ def run_on_pseudo_request(awaitable: Awaitable): invoice_listeners: List[Tuple[str, Callable[[Payment], Awaitable[None]]]] = [] -def register_invoice_listener(ext_name: str, callback: Callable[[Payment], Awaitable[None]]): +def register_invoice_listener(ext_name: str, cb: Callable[[Payment], Awaitable[None]]): """ A method intended for extensions to call when they want to be notified about new invoice payments incoming. """ - print("registering callback", callback) - invoice_listeners.append((ext_name, callback)) + print(f"registering {ext_name} invoice_listener callback: {cb}") + invoice_listeners.append((ext_name, cb)) async def webhook_handler(): @@ -61,7 +61,6 @@ async def invoice_listener(app): async def _invoice_listener(): async for checking_id in WALLET.paid_invoices_stream(): - # do this just so the g object is available g.db = await open_db() payment = await get_standalone_payment(checking_id) if payment.is_in: diff --git a/lnbits/extensions/lnurlp/__init__.py b/lnbits/extensions/lnurlp/__init__.py index 319c25650..2e3e16834 100644 --- a/lnbits/extensions/lnurlp/__init__.py +++ b/lnbits/extensions/lnurlp/__init__.py @@ -6,6 +6,7 @@ lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", te from .views_api import * # noqa from .views import * # noqa +from .lnurl import * # noqa from .tasks import on_invoice_paid from lnbits.core.tasks import register_invoice_listener diff --git a/lnbits/extensions/lnurlp/crud.py b/lnbits/extensions/lnurlp/crud.py index b882afc09..adebb84ac 100644 --- a/lnbits/extensions/lnurlp/crud.py +++ b/lnbits/extensions/lnurlp/crud.py @@ -1,11 +1,12 @@ from typing import List, Optional, Union +from lnbits import bolt11 from lnbits.db import open_ext_db from .models import PayLink -def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink: +def create_pay_link(*, wallet_id: str, description: str, amount: int, webhook_url: str) -> Optional[PayLink]: with open_ext_db("lnurlp") as db: db.execute( """ @@ -14,26 +15,36 @@ def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink description, amount, served_meta, - served_pr + served_pr, + webhook_url ) - VALUES (?, ?, ?, 0, 0) + VALUES (?, ?, ?, 0, 0, ?) """, - (wallet_id, description, amount), + (wallet_id, description, amount, webhook_url), ) link_id = db.cursor.lastrowid return get_pay_link(link_id) -def get_pay_link(link_id: str) -> Optional[PayLink]: +def get_pay_link(link_id: int) -> 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]: +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 * FROM pay_links WHERE unique_hash = ?", (unique_hash,)) + 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 @@ -49,7 +60,7 @@ def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: return [PayLink.from_row(row) for row in rows] -def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: +def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) with open_ext_db("lnurlp") as db: @@ -59,7 +70,7 @@ def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: return PayLink.from_row(row) if row else None -def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: +def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) with open_ext_db("lnurlp") as db: @@ -69,6 +80,30 @@ def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: return PayLink.from_row(row) if row else None -def delete_pay_link(link_id: str) -> None: +def delete_pay_link(link_id: int) -> None: with open_ext_db("lnurlp") as db: 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), + ) diff --git a/lnbits/extensions/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py new file mode 100644 index 000000000..747e5bac1 --- /dev/null +++ b/lnbits/extensions/lnurlp/lnurl.py @@ -0,0 +1,49 @@ +import hashlib +from http import HTTPStatus +from quart import jsonify, url_for +from lnurl import LnurlPayResponse, LnurlPayActionResponse +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl + +from lnbits.core.services import create_invoice + +from lnbits.extensions.lnurlp import lnurlp_ext +from .crud import increment_pay_link, save_link_invoice + + +@lnurlp_ext.route("/api/v1/lnurl/", methods=["GET"]) +async 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 + + url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True) + + 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"]) +async 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(), + extra={"tag": "lnurlp"}, + ) + + save_link_invoice(link_id, payment_request) + + resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[]) + + return jsonify(resp.dict()), HTTPStatus.OK diff --git a/lnbits/extensions/lnurlp/migrations.py b/lnbits/extensions/lnurlp/migrations.py index e5569df7d..d9c61d360 100644 --- a/lnbits/extensions/lnurlp/migrations.py +++ b/lnbits/extensions/lnurlp/migrations.py @@ -16,19 +16,20 @@ def m001_initial(db): ) -# def m002_webhooks_and_success_actions(db): -# """ -# Webhooks and success actions. -# """ -# db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;") -# db.execute("ALTER TABLE pay_links ADD COLUMN success_text TEXT;") -# db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;") -# db.execute( -# """ -# CREATE TABLE invoices ( -# payment_hash PRIMARY KEY, -# link_id INTEGER NOT NULL REFERENCES pay_links (id), -# webhook_sent BOOLEAN NOT NULL DEFAULT false -# ); -# """ -# ) +def m002_webhooks_and_success_actions(db): + """ + Webhooks and success actions. + """ + db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;") + db.execute("ALTER TABLE pay_links ADD COLUMN success_text TEXT;") + db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;") + db.execute( + """ + CREATE TABLE invoices ( + pay_link INTEGER NOT NULL REFERENCES pay_links (id), + payment_hash TEXT NOT NULL, + webhook_sent INT, -- null means not sent, otherwise store status + expiry INT + ); + """ + ) diff --git a/lnbits/extensions/lnurlp/tasks.py b/lnbits/extensions/lnurlp/tasks.py index 3e986b210..cdf5952f7 100644 --- a/lnbits/extensions/lnurlp/tasks.py +++ b/lnbits/extensions/lnurlp/tasks.py @@ -2,11 +2,29 @@ import aiohttp from lnbits.core.models import Payment +from .crud import get_pay_link_by_invoice, mark_webhook_sent + async def on_invoice_paid(payment: Payment) -> None: - islnurlp = "lnurlp" in payment.extra.get("tags", {}) - print("invoice paid on lnurlp?", islnurlp) + islnurlp = "lnurlp" == payment.extra.get("tag") if islnurlp: - print("dispatching webhook") - async with aiohttp.ClientSession() as session: - await session.post("https://fiatjaf.free.beeceptor.com", json=payment) + 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 aiohttp.ClientSession() as session: + try: + r = await session.post( + pay_link.webhook_url, + json={ + "payment_hash": payment.payment_hash, + "payment_request": payment.bolt11, + "amount": payment.amount, + "lnurlp": pay_link.id, + }, + timeout=60, + ) + mark_webhook_sent(payment.payment_hash, r.status) + except aiohttp.client_exceptions.ClientError: + mark_webhook_sent(payment.payment_hash, -1) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html index 8a7206ddc..324df67b2 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -131,6 +131,13 @@ type="number" label="Amount (sat) *" > +
Create pay link @@ -174,6 +182,7 @@

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

{% endraw %}
@@ -248,6 +257,12 @@ align: 'right', label: 'Amount (sat)', field: 'amount' + }, + { + name: 'webhook_url', + align: 'left', + label: 'Webhook URL', + field: 'webhook_url' } ], pagination: { @@ -331,7 +346,7 @@ 'PUT', '/lnurlp/api/v1/links/' + data.id, wallet.adminkey, - _.pick(data, 'description', 'amount') + _.pick(data, 'description', 'amount', 'webhook_url') ) .then(function (response) { self.payLinks = _.reject(self.payLinks, function (obj) { diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py index 3ad9ac030..e54854750 100644 --- a/lnbits/extensions/lnurlp/views_api.py +++ b/lnbits/extensions/lnurlp/views_api.py @@ -1,12 +1,8 @@ -import hashlib -from quart import g, jsonify, request, url_for +from quart import g, jsonify, request from http import HTTPStatus -from lnurl import LnurlPayResponse, LnurlPayActionResponse from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl -from lnbits import bolt11 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.extensions.lnurlp import lnurlp_ext @@ -15,7 +11,6 @@ from .crud import ( get_pay_link, get_pay_links, update_pay_link, - increment_pay_link, delete_pay_link, ) @@ -61,6 +56,7 @@ async def api_link_retrieve(link_id): schema={ "description": {"type": "string", "empty": False, "required": True}, "amount": {"type": "integer", "min": 1, "required": True}, + "webhook_url": {"type": "string", "required": False}, } ) async def api_link_create_or_update(link_id=None): @@ -94,43 +90,3 @@ async def api_link_delete(link_id): delete_pay_link(link_id) return "", HTTPStatus.NO_CONTENT - - -@lnurlp_ext.route("/api/v1/lnurl/", methods=["GET"]) -async 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 - - url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True) - - 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"]) -async 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(), - extra={"tag": "lnurlp"}, - ) - - inv = bolt11.decode(payment_request) - inv.payment_hash - - resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[]) - - return jsonify(resp.dict()), HTTPStatus.OK