Browse Source

lnurlp webhooks.

atmext
fiatjaf 4 years ago
parent
commit
74117ffc57
  1. 7
      lnbits/core/tasks.py
  2. 1
      lnbits/extensions/lnurlp/__init__.py
  3. 55
      lnbits/extensions/lnurlp/crud.py
  4. 49
      lnbits/extensions/lnurlp/lnurl.py
  5. 33
      lnbits/extensions/lnurlp/migrations.py
  6. 28
      lnbits/extensions/lnurlp/tasks.py
  7. 19
      lnbits/extensions/lnurlp/templates/lnurlp/index.html
  8. 48
      lnbits/extensions/lnurlp/views_api.py

7
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:

1
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

55
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),
)

49
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/<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

33
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
);
"""
)

28
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)

19
lnbits/extensions/lnurlp/templates/lnurlp/index.html

@ -131,6 +131,13 @@
type="number"
label="Amount (sat) *"
></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">
<q-btn
v-if="formDialog.data.id"
@ -149,7 +156,8 @@
(
formDialog.data.amount == null ||
formDialog.data.amount < 1
)"
)
"
type="submit"
>Create pay link</q-btn
>
@ -174,6 +182,7 @@
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br />
<strong>Webhook:</strong> {{ qrCodeDialog.data.webhook_url }}<br />
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
@ -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) {

48
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/<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

Loading…
Cancel
Save