Browse Source

basic invoice listeners.

atmext
fiatjaf 4 years ago
parent
commit
04222f1f01
  1. 19
      lnbits/app.py
  2. 13
      lnbits/core/crud.py
  3. 13
      lnbits/core/models.py
  4. 42
      lnbits/core/tasks.py
  5. 19
      lnbits/core/views/api.py
  6. 5
      lnbits/extensions/lnurlp/__init__.py
  7. 18
      lnbits/extensions/lnurlp/migrations.py
  8. 11
      lnbits/extensions/lnurlp/models.py
  9. 12
      lnbits/extensions/lnurlp/tasks.py
  10. 5
      lnbits/extensions/lnurlp/views_api.py
  11. 40
      lnbits/wallets/lnpay.py
  12. 22
      lnbits/wallets/spark.py

19
lnbits/app.py

@ -1,4 +1,5 @@
import importlib import importlib
import asyncio
from quart import Quart, g from quart import Quart, g
from quart_cors import cors # type: ignore from quart_cors import cors # type: ignore
@ -30,6 +31,7 @@ def create_app(config_object="lnbits.settings") -> Quart:
register_filters(app) register_filters(app)
register_commands(app) register_commands(app)
register_request_hooks(app) register_request_hooks(app)
register_async_tasks(app)
return app return app
@ -86,3 +88,20 @@ def register_request_hooks(app: Quart):
@app.teardown_request @app.teardown_request
async def after_request(exc): async def after_request(exc):
g.db.__exit__(type(exc), exc, None) g.db.__exit__(type(exc), exc, None)
def register_async_tasks(app):
from lnbits.core.tasks import invoice_listener, webhook_handler
@app.route("/wallet/webhook")
async def webhook_listener():
return await webhook_handler()
@app.before_serving
async def listeners():
loop = asyncio.get_event_loop()
loop.create_task(invoice_listener(app))
@app.after_serving
async def stop_listeners():
pass

13
lnbits/core/crud.py

@ -131,6 +131,19 @@ def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
# --------------- # ---------------
def get_standalone_payment(checking_id: str) -> Optional[Payment]:
row = g.db.fetchone(
"""
SELECT *
FROM apipayments
WHERE checking_id = ?
""",
(checking_id,),
)
return Payment.from_row(row) if row else None
def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]: def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]:
row = g.db.fetchone( row = g.db.fetchone(
""" """

13
lnbits/core/models.py

@ -2,6 +2,8 @@ import json
from typing import List, NamedTuple, Optional, Dict from typing import List, NamedTuple, Optional, Dict
from sqlite3 import Row from sqlite3 import Row
from lnbits.settings import WALLET
class User(NamedTuple): class User(NamedTuple):
id: str id: str
@ -113,6 +115,17 @@ class Payment(NamedTuple):
update_payment_status(self.checking_id, pending) update_payment_status(self.checking_id, pending)
def check_pending(self) -> None:
if self.is_uncheckable:
return
if self.is_out:
pending = WALLET.get_payment_status(self.checking_id)
else:
pending = WALLET.get_invoice_status(self.checking_id)
self.set_pending(pending.pending)
def delete(self) -> None: def delete(self) -> None:
from .crud import delete_payment from .crud import delete_payment

42
lnbits/core/tasks.py

@ -1,9 +1,13 @@
import asyncio import asyncio
from typing import Optional, Awaitable from typing import Optional, List, Awaitable, Tuple, Callable
from quart import Quart, Request, g from quart import Quart, Request, g
from werkzeug.datastructures import Headers from werkzeug.datastructures import Headers
from lnbits.db import open_db from lnbits.db import open_db, open_ext_db
from lnbits.settings import WALLET
from .models import Payment
from .crud import get_standalone_payment
main_app: Optional[Quart] = None main_app: Optional[Quart] = None
@ -31,3 +35,37 @@ def run_on_pseudo_request(awaitable: Awaitable):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.create_task(run(awaitable)) loop.create_task(run(awaitable))
invoice_listeners: List[Tuple[str, Callable[[Payment], Awaitable[None]]]] = []
def register_invoice_listener(ext_name: str, callback: 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))
async def webhook_handler():
handler = getattr(WALLET, "webhook_listener", None)
if handler:
await handler()
async def invoice_listener(app):
run_on_pseudo_request(_invoice_listener())
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:
await payment.set_pending(False)
for ext_name, cb in invoice_listeners:
g.ext_db = await open_ext_db(ext_name)
cb(payment)

19
lnbits/core/views/api.py

@ -7,7 +7,6 @@ from lnbits.core import core_app
from lnbits.core.services import create_invoice, pay_invoice from lnbits.core.services import create_invoice, pay_invoice
from lnbits.core.crud import delete_expired_invoices from lnbits.core.crud import delete_expired_invoices
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.settings import WALLET
@core_app.route("/api/v1/wallet", methods=["GET"]) @core_app.route("/api/v1/wallet", methods=["GET"])
@ -32,10 +31,7 @@ async def api_payments():
delete_expired_invoices() delete_expired_invoices()
for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True): for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True):
if payment.is_out: payment.check_pending()
payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
else:
payment.set_pending(WALLET.get_invoice_status(payment.checking_id).pending)
return jsonify(g.wallet.get_payments(pending=True)), HTTPStatus.OK return jsonify(g.wallet.get_payments(pending=True)), HTTPStatus.OK
@ -123,17 +119,8 @@ async def api_payment(payment_hash):
return jsonify({"paid": True}), HTTPStatus.OK return jsonify({"paid": True}), HTTPStatus.OK
try: try:
if payment.is_uncheckable: payment.check_pending()
pass
elif payment.is_out:
is_paid = not WALLET.get_payment_status(payment.checking_id).pending
elif payment.is_in:
is_paid = not WALLET.get_invoice_status(payment.checking_id).pending
except Exception: except Exception:
return jsonify({"paid": False}), HTTPStatus.OK return jsonify({"paid": False}), HTTPStatus.OK
if is_paid: return jsonify({"paid": not payment.pending}), HTTPStatus.OK
payment.set_pending(False)
return jsonify({"paid": True}), HTTPStatus.OK
return jsonify({"paid": False}), HTTPStatus.OK

5
lnbits/extensions/lnurlp/__init__.py

@ -6,3 +6,8 @@ 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 .tasks import on_invoice_paid
from lnbits.core.tasks import register_invoice_listener
register_invoice_listener("lnurlp", on_invoice_paid)

18
lnbits/extensions/lnurlp/migrations.py

@ -14,3 +14,21 @@ 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
# );
# """
# )

11
lnbits/extensions/lnurlp/models.py

@ -7,12 +7,15 @@ from typing import NamedTuple
class PayLink(NamedTuple): class PayLink(NamedTuple):
id: str id: int
wallet: str wallet: str
description: str description: str
amount: int amount: int
served_meta: int served_meta: int
served_pr: int served_pr: int
webhook_url: str
success_text: str
success_url: str
@classmethod @classmethod
def from_row(cls, row: Row) -> "PayLink": def from_row(cls, row: Row) -> "PayLink":
@ -27,3 +30,9 @@ class PayLink(NamedTuple):
@property @property
def lnurlpay_metadata(self) -> LnurlPayMetadata: def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.description]])) return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
class Invoice(NamedTuple):
payment_hash: str
link_id: int
webhook_sent: bool

12
lnbits/extensions/lnurlp/tasks.py

@ -0,0 +1,12 @@
import aiohttp
from lnbits.core.models import Payment
async def on_invoice_paid(payment: Payment) -> None:
islnurlp = "lnurlp" in payment.extra.get("tags", {})
print("invoice paid on lnurlp?", islnurlp)
if islnurlp:
print("dispatching webhook")
async with aiohttp.ClientSession() as session:
await session.post("https://fiatjaf.free.beeceptor.com", json=payment)

5
lnbits/extensions/lnurlp/views_api.py

@ -4,6 +4,7 @@ from http import HTTPStatus
from lnurl import LnurlPayResponse, LnurlPayActionResponse 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.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
@ -126,6 +127,10 @@ async def api_lnurl_callback(link_id):
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(), description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
extra={"tag": "lnurlp"}, extra={"tag": "lnurlp"},
) )
inv = bolt11.decode(payment_request)
inv.payment_hash
resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[]) resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[])
return jsonify(resp.dict()), HTTPStatus.OK return jsonify(resp.dict()), HTTPStatus.OK

40
lnbits/wallets/lnpay.py

@ -1,6 +1,9 @@
import asyncio
import aiohttp
from os import getenv from os import getenv
from typing import Optional, Dict from typing import Optional, Dict, AsyncGenerator
from requests import get, post from requests import get, post
from quart import request
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
@ -15,9 +18,13 @@ class LNPayWallet(Wallet):
self.auth_invoice = getenv("LNPAY_INVOICE_KEY") self.auth_invoice = getenv("LNPAY_INVOICE_KEY")
self.auth_read = getenv("LNPAY_READ_KEY") self.auth_read = getenv("LNPAY_READ_KEY")
self.auth_api = {"X-Api-Key": getenv("LNPAY_API_KEY")} self.auth_api = {"X-Api-Key": getenv("LNPAY_API_KEY")}
self.queue = asyncio.Queue()
def create_invoice( def create_invoice(
self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
) -> InvoiceResponse: ) -> InvoiceResponse:
data: Dict = {"num_satoshis": f"{amount}"} data: Dict = {"num_satoshis": f"{amount}"}
if description_hash: if description_hash:
@ -30,7 +37,12 @@ class LNPayWallet(Wallet):
headers=self.auth_api, headers=self.auth_api,
json=data, json=data,
) )
ok, checking_id, payment_request, error_message = r.status_code == 201, None, None, r.text ok, checking_id, payment_request, error_message = (
r.status_code == 201,
None,
None,
r.text,
)
if ok: if ok:
data = r.json() data = r.json()
@ -55,10 +67,30 @@ class LNPayWallet(Wallet):
return self.get_payment_status(checking_id) return self.get_payment_status(checking_id)
def get_payment_status(self, checking_id: str) -> PaymentStatus: def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/user/lntx/{checking_id}", headers=self.auth_api) r = get(
url=f"{self.endpoint}/user/lntx/{checking_id}?fields=settled",
headers=self.auth_api,
)
if not r.ok: if not r.ok:
return PaymentStatus(None) return PaymentStatus(None)
statuses = {0: None, 1: True, -1: False} statuses = {0: None, 1: True, -1: False}
return PaymentStatus(statuses[r.json()["settled"]]) return PaymentStatus(statuses[r.json()["settled"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
yield await self.queue.get()
self.queue.task_done()
async def webhook_listener(self):
data = await request.get_json()
if "event" not in data or data["event"].get("name") != "wallet_receive":
return ""
lntx_id = data["data"]["wtx"]["lnTx"]["id"]
async with aiohttp.ClientSession() as session:
async with session.get(f"{self.endpoint}/user/lntx/{lntx_id}?fields=settled") as resp:
data = await resp.json()
if data["settled"]:
self.queue.put_nowait(lntx_id)

22
lnbits/wallets/spark.py

@ -1,7 +1,9 @@
import random import random
import requests import requests
import json
from aiohttp_sse_client import client as sse_client
from os import getenv from os import getenv
from typing import Optional from typing import Optional, AsyncGenerator
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
@ -16,7 +18,7 @@ class UnknownError(Exception):
class SparkWallet(Wallet): class SparkWallet(Wallet):
def __init__(self): def __init__(self):
self.url = getenv("SPARK_URL") self.url = getenv("SPARK_URL").replace("/rpc", "")
self.token = getenv("SPARK_TOKEN") self.token = getenv("SPARK_TOKEN")
def __getattr__(self, key): def __getattr__(self, key):
@ -28,7 +30,9 @@ class SparkWallet(Wallet):
elif kwargs: elif kwargs:
params = kwargs params = kwargs
r = requests.post(self.url, headers={"X-Access": self.token}, json={"method": key, "params": params}) r = requests.post(
self.url + "/rpc", headers={"X-Access": self.token}, json={"method": key, "params": params}
)
try: try:
data = r.json() data = r.json()
except: except:
@ -91,3 +95,15 @@ class SparkWallet(Wallet):
return PaymentStatus(False) return PaymentStatus(False)
return PaymentStatus(None) return PaymentStatus(None)
raise KeyError("supplied an invalid checking_id") raise KeyError("supplied an invalid checking_id")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = self.url + "/stream?access-key=" + self.token
conn = sse_client.EventSource(url)
async with conn as es:
async for event in es:
try:
if event.type == "inv-paid":
data = json.loads(event.data)
yield data["label"]
except ConnectionError:
pass

Loading…
Cancel
Save