lnurlp webhooks.

This commit is contained in:
fiatjaf 2020-09-28 00:54:15 -03:00
parent 04222f1f01
commit 74117ffc57
8 changed files with 157 additions and 83 deletions

View File

@ -40,13 +40,13 @@ def run_on_pseudo_request(awaitable: Awaitable):
invoice_listeners: List[Tuple[str, Callable[[Payment], Awaitable[None]]]] = [] 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 A method intended for extensions to call when they want to be notified about
new invoice payments incoming. new invoice payments incoming.
""" """
print("registering callback", callback) print(f"registering {ext_name} invoice_listener callback: {cb}")
invoice_listeners.append((ext_name, callback)) invoice_listeners.append((ext_name, cb))
async def webhook_handler(): async def webhook_handler():
@ -61,7 +61,6 @@ async def invoice_listener(app):
async def _invoice_listener(): async def _invoice_listener():
async for checking_id in WALLET.paid_invoices_stream(): async for checking_id in WALLET.paid_invoices_stream():
# do this just so the g object is available
g.db = await open_db() g.db = await open_db()
payment = await get_standalone_payment(checking_id) payment = await get_standalone_payment(checking_id)
if payment.is_in: if payment.is_in:

View File

@ -6,6 +6,7 @@ lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", te
from .views_api import * # noqa from .views_api import * # noqa
from .views import * # noqa from .views import * # noqa
from .lnurl import * # noqa
from .tasks import on_invoice_paid from .tasks import on_invoice_paid
from lnbits.core.tasks import register_invoice_listener from lnbits.core.tasks import register_invoice_listener

View File

@ -1,11 +1,12 @@
from typing import List, Optional, Union from typing import List, Optional, Union
from lnbits import bolt11
from lnbits.db import open_ext_db from lnbits.db import open_ext_db
from .models import PayLink 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: with open_ext_db("lnurlp") as db:
db.execute( db.execute(
""" """
@ -14,26 +15,36 @@ def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink
description, description,
amount, amount,
served_meta, 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 link_id = db.cursor.lastrowid
return get_pay_link(link_id) 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: with open_ext_db("lnurlp") as db:
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None 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: 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 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] 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()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("lnurlp") as db: 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 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()]) q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
with open_ext_db("lnurlp") as db: 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 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: with open_ext_db("lnurlp") as db:
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,)) 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),
)

View File

@ -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/<link_id>", 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/<link_id>", 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

View File

@ -16,19 +16,20 @@ def m001_initial(db):
) )
# def m002_webhooks_and_success_actions(db): def m002_webhooks_and_success_actions(db):
# """ """
# Webhooks and success actions. Webhooks and success actions.
# """ """
# db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;") 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_text TEXT;")
# db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;") db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;")
# db.execute( db.execute(
# """ """
# CREATE TABLE invoices ( CREATE TABLE invoices (
# payment_hash PRIMARY KEY, pay_link INTEGER NOT NULL REFERENCES pay_links (id),
# link_id INTEGER NOT NULL REFERENCES pay_links (id), payment_hash TEXT NOT NULL,
# webhook_sent BOOLEAN NOT NULL DEFAULT false webhook_sent INT, -- null means not sent, otherwise store status
# ); expiry INT
# """ );
# ) """
)

View File

@ -2,11 +2,29 @@ import aiohttp
from lnbits.core.models import Payment 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: async def on_invoice_paid(payment: Payment) -> None:
islnurlp = "lnurlp" in payment.extra.get("tags", {}) islnurlp = "lnurlp" == payment.extra.get("tag")
print("invoice paid on lnurlp?", islnurlp)
if islnurlp: if islnurlp:
print("dispatching webhook") pay_link = get_pay_link_by_invoice(payment.payment_hash)
async with aiohttp.ClientSession() as session: if not pay_link:
await session.post("https://fiatjaf.free.beeceptor.com", json=payment) # 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)

View File

@ -131,6 +131,13 @@
type="number" type="number"
label="Amount (sat) *" label="Amount (sat) *"
></q-input> ></q-input>
<q-input
filled
dense
v-model="formDialog.data.webhook_url"
type="text"
label="Webhook URL (optional)"
></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
v-if="formDialog.data.id" v-if="formDialog.data.id"
@ -149,7 +156,8 @@
( (
formDialog.data.amount == null || formDialog.data.amount == null ||
formDialog.data.amount < 1 formDialog.data.amount < 1
)" )
"
type="submit" type="submit"
>Create pay link</q-btn >Create pay link</q-btn
> >
@ -174,6 +182,7 @@
<p style="word-break: break-all"> <p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br /> <strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br /> <strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br />
<strong>Webhook:</strong> {{ qrCodeDialog.data.webhook_url }}<br />
</p> </p>
{% endraw %} {% endraw %}
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
@ -248,6 +257,12 @@
align: 'right', align: 'right',
label: 'Amount (sat)', label: 'Amount (sat)',
field: 'amount' field: 'amount'
},
{
name: 'webhook_url',
align: 'left',
label: 'Webhook URL',
field: 'webhook_url'
} }
], ],
pagination: { pagination: {
@ -331,7 +346,7 @@
'PUT', 'PUT',
'/lnurlp/api/v1/links/' + data.id, '/lnurlp/api/v1/links/' + data.id,
wallet.adminkey, wallet.adminkey,
_.pick(data, 'description', 'amount') _.pick(data, 'description', 'amount', 'webhook_url')
) )
.then(function (response) { .then(function (response) {
self.payLinks = _.reject(self.payLinks, function (obj) { self.payLinks = _.reject(self.payLinks, function (obj) {

View File

@ -1,12 +1,8 @@
import hashlib from quart import g, jsonify, request
from quart import g, jsonify, request, url_for
from http import HTTPStatus from http import HTTPStatus
from lnurl import LnurlPayResponse, LnurlPayActionResponse
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from lnbits import bolt11
from lnbits.core.crud import get_user 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.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.extensions.lnurlp import lnurlp_ext from lnbits.extensions.lnurlp import lnurlp_ext
@ -15,7 +11,6 @@ from .crud import (
get_pay_link, get_pay_link,
get_pay_links, get_pay_links,
update_pay_link, update_pay_link,
increment_pay_link,
delete_pay_link, delete_pay_link,
) )
@ -61,6 +56,7 @@ async def api_link_retrieve(link_id):
schema={ schema={
"description": {"type": "string", "empty": False, "required": True}, "description": {"type": "string", "empty": False, "required": True},
"amount": {"type": "integer", "min": 1, "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): 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) delete_pay_link(link_id)
return "", HTTPStatus.NO_CONTENT return "", HTTPStatus.NO_CONTENT
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", 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/<link_id>", 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