From 27e170d30471927eee555d12b1ed4a87663a9623 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Fri, 16 Apr 2021 23:36:13 -0300 Subject: [PATCH 1/9] livestream: allow tracks to be edited. --- lnbits/extensions/livestream/crud.py | 37 +++++++++++----- .../extensions/livestream/static/js/index.js | 44 ++++++++++++------- .../templates/livestream/index.html | 19 ++++++-- lnbits/extensions/livestream/views_api.py | 43 +++++++++++++----- 4 files changed, 100 insertions(+), 43 deletions(-) diff --git a/lnbits/extensions/livestream/crud.py b/lnbits/extensions/livestream/crud.py index 0d2f98f9..1c1043db 100644 --- a/lnbits/extensions/livestream/crud.py +++ b/lnbits/extensions/livestream/crud.py @@ -1,4 +1,3 @@ -import unicodedata from typing import List, Optional from lnbits.core.crud import create_account, create_wallet @@ -65,22 +64,36 @@ async def add_track( name: str, download_url: Optional[str], price_msat: int, - producer_name: Optional[str], - producer_id: Optional[int], + producer: Optional[int], ) -> int: - if producer_id: - p_id = producer_id - elif producer_name: - p_id = await add_producer(livestream, producer_name) - else: - raise TypeError("need either producer_id or producer_name arguments") - result = await db.execute( """ INSERT INTO tracks (livestream, name, download_url, price_msat, producer) VALUES (?, ?, ?, ?, ?) """, - (livestream, name, download_url, price_msat, p_id), + (livestream, name, download_url, price_msat, producer), + ) + return result._result_proxy.lastrowid + + +async def update_track( + livestream: int, + track_id: int, + name: str, + download_url: Optional[str], + price_msat: int, + producer: int, +) -> int: + result = await db.execute( + """ + UPDATE tracks SET + name = ?, + download_url = ?, + price_msat = ?, + producer = ? + WHERE livestream = ? AND id = ? + """, + (name, download_url, price_msat, producer, livestream, track_id), ) return result._result_proxy.lastrowid @@ -120,7 +133,7 @@ async def delete_track_from_livestream(livestream: int, track_id: int): async def add_producer(livestream: int, name: str) -> int: - name = "".join([unicodedata.normalize("NFD", l)[0] for l in name if l]).strip() + name = name.strip() existing = await db.fetchall( """ diff --git a/lnbits/extensions/livestream/static/js/index.js b/lnbits/extensions/livestream/static/js/index.js index ac00fa80..faa439cb 100644 --- a/lnbits/extensions/livestream/static/js/index.js +++ b/lnbits/extensions/livestream/static/js/index.js @@ -93,24 +93,21 @@ new Vue({ ) }, addTrack() { - let {name, producer, price_sat, download_url} = this.trackDialog.data + let {id, name, producer, price_sat, download_url} = this.trackDialog.data + + const [method, path] = id + ? ['PUT', `/livestream/api/v1/livestream/tracks/${id}`] + : ['POST', '/livestream/api/v1/livestream/tracks'] LNbits.api - .request( - 'POST', - '/livestream/api/v1/livestream/tracks', - this.selectedWallet.inkey, - { - download_url: - download_url && download_url.length > 0 - ? download_url - : undefined, - name, - price_msat: price_sat * 1000 || 0, - producer_name: typeof producer === 'string' ? producer : undefined, - producer_id: typeof producer === 'object' ? producer.id : undefined - } - ) + .request(method, path, this.selectedWallet.inkey, { + download_url: + download_url && download_url.length > 0 ? download_url : undefined, + name, + price_msat: price_sat * 1000 || 0, + producer_name: typeof producer === 'string' ? producer : undefined, + producer_id: typeof producer === 'object' ? producer.id : undefined + }) .then(response => { this.$q.notify({ message: `Track '${this.trackDialog.data.name}' added.`, @@ -124,6 +121,21 @@ new Vue({ LNbits.utils.notifyApiError(err) }) }, + openAddTrackDialog() { + this.trackDialog.show = true + this.trackDialog.data = {} + }, + openUpdateDialog(itemId) { + this.trackDialog.show = true + let item = this.livestream.tracks.find(item => item.id === itemId) + this.trackDialog.data = { + ...item, + producer: this.livestream.producers.find( + prod => prod.id === item.producer + ), + price_sat: Math.round(item.price_msat / 1000) + } + }, deleteTrack(trackId) { LNbits.utils .confirmDialog('Are you sure you want to delete this track?') diff --git a/lnbits/extensions/livestream/templates/livestream/index.html b/lnbits/extensions/livestream/templates/livestream/index.html index 5cf1e3c8..e73293d2 100644 --- a/lnbits/extensions/livestream/templates/livestream/index.html +++ b/lnbits/extensions/livestream/templates/livestream/index.html @@ -64,7 +64,7 @@ Add new track @@ -107,12 +107,20 @@ {{ producersMap[props.row.producer].name }} {{ props.row.price_msat }}{{ Math.round(props.row.price_msat / 1000) }} {{ props.row.download_url }} + - + Add track + Update track + Add track +
diff --git a/lnbits/extensions/livestream/views_api.py b/lnbits/extensions/livestream/views_api.py index 1150ddb9..cf970817 100644 --- a/lnbits/extensions/livestream/views_api.py +++ b/lnbits/extensions/livestream/views_api.py @@ -9,9 +9,11 @@ from .crud import ( get_or_create_livestream_by_wallet, add_track, get_tracks, + update_track, + add_producer, get_producers, - update_livestream_fee, update_current_track, + update_livestream_fee, delete_track_from_livestream, ) @@ -72,6 +74,7 @@ async def api_update_fee(fee_pct): @livestream_ext.route("/api/v1/livestream/tracks", methods=["POST"]) +@livestream_ext.route("/api/v1/livestream/tracks/", methods=["PUT"]) @api_check_wallet_key("invoice") @api_validate_post_request( schema={ @@ -90,17 +93,35 @@ async def api_update_fee(fee_pct): }, } ) -async def api_add_track(): +async def api_add_track(id=None): ls = await get_or_create_livestream_by_wallet(g.wallet.id) - await add_track( - ls.id, - g.data["name"], - g.data.get("download_url"), - g.data.get("price_msat", 0), - g.data.get("producer_name"), - g.data.get("producer_id"), - ) - return "", HTTPStatus.CREATED + + if "producer_id" in g.data: + p_id = g.data["producer_id"] + elif "producer_name" in g.data: + p_id = await add_producer(ls.id, g.data["producer_name"]) + else: + raise TypeError("need either producer_id or producer_name arguments") + + if id: + await update_track( + ls.id, + id, + g.data["name"], + g.data.get("download_url"), + g.data.get("price_msat", 0), + p_id, + ) + return "", HTTPStatus.OK + else: + await add_track( + ls.id, + g.data["name"], + g.data.get("download_url"), + g.data.get("price_msat", 0), + p_id, + ) + return "", HTTPStatus.CREATED @livestream_ext.route("/api/v1/livestream/tracks/", methods=["DELETE"]) From f08d86c6df7b55e4abcafc23776851477638452f Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sat, 17 Apr 2021 00:15:03 -0300 Subject: [PATCH 2/9] livestream: a standalone QR code for each track. --- lnbits/extensions/livestream/lnurl.py | 23 ++++++++++++++++++- lnbits/extensions/livestream/models.py | 7 +++++- .../templates/livestream/index.html | 20 ++++++++++++++++ lnbits/extensions/livestream/views_api.py | 5 +++- 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/lnbits/extensions/livestream/lnurl.py b/lnbits/extensions/livestream/lnurl.py index d1c59fb1..1e021f85 100644 --- a/lnbits/extensions/livestream/lnurl.py +++ b/lnbits/extensions/livestream/lnurl.py @@ -10,7 +10,7 @@ from .crud import get_livestream, get_livestream_by_track, get_track @livestream_ext.route("/lnurl/", methods=["GET"]) -async def lnurl_response(ls_id): +async def lnurl_livestream(ls_id): ls = await get_livestream(ls_id) if not ls: return jsonify({"status": "ERROR", "reason": "Livestream not found."}) @@ -34,6 +34,27 @@ async def lnurl_response(ls_id): return jsonify(params) +@livestream_ext.route("/lnurl/t/", methods=["GET"]) +async def lnurl_track(track_id): + track = await get_track(track_id) + if not track: + return jsonify({"status": "ERROR", "reason": "Track not found."}) + + resp = LnurlPayResponse( + callback=url_for( + "livestream.lnurl_callback", track_id=track.id, _external=True + ), + min_sendable=track.min_sendable, + max_sendable=track.max_sendable, + metadata=await track.lnurlpay_metadata(), + ) + + params = resp.dict() + params["commentAllowed"] = 300 + + return jsonify(params) + + @livestream_ext.route("/lnurl/cb/", methods=["GET"]) async def lnurl_callback(track_id): track = await get_track(track_id) diff --git a/lnbits/extensions/livestream/models.py b/lnbits/extensions/livestream/models.py index 3385970b..bfe82973 100644 --- a/lnbits/extensions/livestream/models.py +++ b/lnbits/extensions/livestream/models.py @@ -14,7 +14,7 @@ class Livestream(NamedTuple): @property def lnurl(self) -> Lnurl: - url = url_for("livestream.lnurl_response", ls_id=self.id, _external=True) + url = url_for("livestream.lnurl_livestream", ls_id=self.id, _external=True) return lnurl_encode(url) @@ -33,6 +33,11 @@ class Track(NamedTuple): def max_sendable(self) -> int: return max(50_000_000, self.price_msat * 5) + @property + def lnurl(self) -> Lnurl: + url = url_for("livestream.lnurl_track", track_id=self.id, _external=True) + return lnurl_encode(url) + async def fullname(self) -> str: from .crud import get_producer diff --git a/lnbits/extensions/livestream/templates/livestream/index.html b/lnbits/extensions/livestream/templates/livestream/index.html index e73293d2..f242744a 100644 --- a/lnbits/extensions/livestream/templates/livestream/index.html +++ b/lnbits/extensions/livestream/templates/livestream/index.html @@ -229,6 +229,26 @@ + +

Standalone QR Code for this track

+
+ + + + + + Copy LNURL-pay code + Date: Sat, 17 Apr 2021 18:27:15 -0300 Subject: [PATCH 3/9] lnurl balanceCheck and balanceNotify. --- lnbits/core/crud.py | 77 +++++++++++++++++++- lnbits/core/migrations.py | 29 ++++++++ lnbits/core/models.py | 28 ++++++++ lnbits/core/services.py | 39 ++++++++--- lnbits/core/tasks.py | 15 ++++ lnbits/core/templates/core/wallet.html | 28 +++++++- lnbits/core/views/api.py | 23 +++++- lnbits/core/views/generic.py | 97 ++++++++++++++++++-------- lnbits/tasks.py | 10 +++ 9 files changed, 303 insertions(+), 43 deletions(-) diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index f3abb75f..b9f02070 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -2,13 +2,14 @@ import json import datetime from uuid import uuid4 from typing import List, Optional, Dict, Any +from urllib.parse import urlparse from lnbits import bolt11 from lnbits.db import Connection from lnbits.settings import DEFAULT_WALLET_NAME from . import db -from .models import User, Wallet, Payment +from .models import User, Wallet, Payment, BalanceCheck # accounts @@ -379,3 +380,77 @@ async def check_internal( return None else: return row["checking_id"] + + +# balance_check +# ------------- + + +async def save_balance_check( + wallet_id: str, + url: str, + conn: Optional[Connection] = None, +): + domain = urlparse(url).netloc + + await (conn or db).execute( + """ + INSERT OR REPLACE INTO balance_check (wallet, service, url) + VALUES (?, ?, ?) + """, + (wallet_id, domain, url), + ) + + +async def get_balance_check( + wallet_id: str, + domain: str, + conn: Optional[Connection] = None, +) -> Optional[BalanceCheck]: + row = await (conn or db).fetchone( + """ + SELECT wallet, service, url + FROM balance_check + WHERE wallet = ? AND service = ? + """, + (wallet_id, domain), + ) + return BalanceCheck.from_row(row) if row else None + + +async def get_balance_checks(conn: Optional[Connection] = None) -> List[BalanceCheck]: + rows = await (conn or db).fetchall("SELECT wallet, service, url FROM balance_check") + return [BalanceCheck.from_row(row) for row in rows] + + +# balance_notify +# -------------- + + +async def save_balance_notify( + wallet_id: str, + url: str, + conn: Optional[Connection] = None, +): + await (conn or db).execute( + """ + INSERT OR REPLACE INTO balance_notify (wallet, url) + VALUES (?, ?) + """, + (wallet_id, url), + ) + + +async def get_balance_notify( + wallet_id: str, + conn: Optional[Connection] = None, +) -> Optional[str]: + row = await (conn or db).fetchone( + """ + SELECT url + FROM balance_notify + WHERE wallet = ? + """, + (wallet_id,), + ) + return row[0] if row else None diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index 0f14d9df..64de9acf 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -161,3 +161,32 @@ async def m004_ensure_fees_are_always_negative(db): GROUP BY wallet; """ ) + + +async def m005_balance_check_balance_notify(db): + """ + Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets. + """ + + await db.execute( + """ + CREATE TABLE balance_check ( + wallet INTEGER NOT NULL REFERENCES wallets (id), + service TEXT NOT NULL, + url TEXT NOT NULL, + + UNIQUE(wallet, service) + ); + """ + ) + + await db.execute( + """ + CREATE TABLE balance_notify ( + wallet INTEGER NOT NULL REFERENCES wallets (id), + url TEXT NOT NULL, + + UNIQUE(wallet, url) + ); + """ + ) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index d0b648c1..b2cd70af 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -1,7 +1,9 @@ import json import hmac import hashlib +from quart import url_for from ecdsa import SECP256k1, SigningKey # type: ignore +from lnurl import encode as lnurl_encode # type: ignore from typing import List, NamedTuple, Optional, Dict from sqlite3 import Row @@ -36,6 +38,22 @@ class Wallet(NamedTuple): def balance(self) -> int: return self.balance_msat // 1000 + @property + def withdrawable_balance(self) -> int: + from .services import fee_reserve + + return self.balance_msat - fee_reserve(self.balance_msat) + + @property + def lnurlwithdraw_full(self) -> str: + url = url_for( + "core.lnurl_full_withdraw", + usr=self.user, + wal=self.id, + _external=True, + ) + return lnurl_encode(url) + def lnurlauth_key(self, domain: str) -> SigningKey: hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest() linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256") @@ -158,3 +176,13 @@ class Payment(NamedTuple): from .crud import delete_payment await delete_payment(self.checking_id) + + +class BalanceCheck(NamedTuple): + wallet: str + service: str + url: str + + @classmethod + def from_row(cls, row: Row): + return cls(wallet=row["wallet"], service=row["service"], url=row["url"]) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 28203c58..39b2eaed 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -4,8 +4,8 @@ from io import BytesIO from binascii import unhexlify from typing import Optional, Tuple, Dict from urllib.parse import urlparse, parse_qs -from quart import g -from lnurl import LnurlErrorResponse, LnurlWithdrawResponse # type: ignore +from quart import g, url_for +from lnurl import LnurlErrorResponse, decode as decode_lnurl # type: ignore try: from typing import TypedDict # type: ignore @@ -128,10 +128,9 @@ async def pay_invoice( else: # create a temporary payment here so we can check if # the balance is enough in the next step - fee_reserve = max(1000, int(invoice.amount_msat * 0.01)) await create_payment( checking_id=temp_id, - fee=-fee_reserve, + fee=-fee_reserve(invoice.amount_msat), conn=conn, **payment_kwargs, ) @@ -180,24 +179,38 @@ async def pay_invoice( async def redeem_lnurl_withdraw( wallet_id: str, - res: LnurlWithdrawResponse, + lnurl_request: str, memo: Optional[str] = None, + extra: Optional[Dict] = None, conn: Optional[Connection] = None, ) -> None: + res = {} + + async with httpx.AsyncClient() as client: + lnurl = decode_lnurl(lnurl_request) + r = await client.get(str(lnurl)) + res = r.json() + _, payment_request = await create_invoice( wallet_id=wallet_id, - amount=res.max_sats, - memo=memo or res.default_description or "", - extra={"tag": "lnurlwallet"}, + amount=res["maxWithdrawable"], + memo=memo or res["defaultDescription"] or "", + extra=extra, conn=conn, ) async with httpx.AsyncClient() as client: await client.get( - res.callback.base, + res["callback"], params={ - **res.callback.query_params, - **{"k1": res.k1, "pr": payment_request}, + "k1": res["k1"], + "pr": payment_request, + "balanceNotify": url_for( + "core.lnurl_balance_notify", + service=urlparse(lnurl_request).netloc, + wal=g.wallet.id, + _external=True, + ), }, ) @@ -286,3 +299,7 @@ async def check_invoice_status( return PaymentStatus(None) return await WALLET.get_invoice_status(payment.checking_id) + + +def fee_reserve(amount_msat: int) -> int: + return max(1000, int(amount_msat * 0.01)) diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index 763ef998..20740c05 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -3,7 +3,9 @@ import httpx from typing import List from lnbits.tasks import register_invoice_listener + from . import db +from .crud import get_balance_notify from .models import Payment sse_listeners: List[trio.MemorySendChannel] = [] @@ -24,6 +26,19 @@ async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): if payment.webhook and not payment.webhook_status: await dispatch_webhook(payment) + # dispatch balance_notify + url = await get_balance_notify(payment.wallet_id) + if url: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + url, + timeout=4, + ) + await mark_webhook_sent(payment, r.status_code) + except (httpx.ConnectError, httpx.RequestError): + pass + async def dispatch_sse(payment: Payment): for send_channel in sse_listeners: diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index f1dd4173..81bd9c66 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -231,13 +231,39 @@ {% include "core/_api_docs.html" %} + + + +

+ This is an LNURL-withdraw QR code for slurping everything from + this wallet. Do not share with anyone. +

+ + + +

+ It is compatible with balanceCheck and + balanceNotify so your wallet may keep pulling the + funds continuously from here after the first withdraw. +

+
+
+
+ - +

This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there. diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 7c8cf8b9..89330ab3 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -3,7 +3,7 @@ import json import lnurl # type: ignore import httpx from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult -from quart import g, jsonify, make_response +from quart import g, jsonify, make_response, url_for from http import HTTPStatus from binascii import unhexlify from typing import Dict, Union @@ -12,6 +12,7 @@ from lnbits import bolt11 from lnbits.decorators import api_check_wallet_key, api_validate_post_request from .. import core_app, db +from ..crud import save_balance_check from ..services import ( PaymentFailure, InvoiceFailure, @@ -60,6 +61,7 @@ async def api_payments(): "excludes": "memo", }, "lnurl_callback": {"type": "string", "nullable": True, "required": False}, + "lnurl_balance_check": {"type": "string", "required": False}, "extra": {"type": "dict", "nullable": True, "required": False}, "webhook": {"type": "string", "empty": False, "required": False}, } @@ -92,11 +94,22 @@ async def api_payments_create_invoice(): lnurl_response: Union[None, bool, str] = None if g.data.get("lnurl_callback"): + if "lnurl_balance_check" in g.data: + save_balance_check(g.wallet.id, g.data["lnurl_balance_check"]) + async with httpx.AsyncClient() as client: try: r = await client.get( g.data["lnurl_callback"], - params={"pr": payment_request}, + params={ + "pr": payment_request, + "balanceNotify": url_for( + "core.lnurl_balance_notify", + service=urlparse(g.data["lnurl_callback"]).netloc, + wal=g.wallet.id, + _external=True, + ), + }, timeout=10, ) if r.is_error: @@ -387,6 +400,12 @@ async def api_lnurlscan(code: str): parsed_callback: ParseResult = urlparse(data.callback) qs: Dict = parse_qs(parsed_callback.query) qs["k1"] = data.k1 + + # balanceCheck/balanceNotify + if "balanceCheck" in jdata: + params.update(balanceCheck=jdata["balanceCheck"]) + + # format callback url and send to client parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True)) params.update(callback=urlunparse(parsed_callback)) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 495d16ae..38451189 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -1,5 +1,4 @@ import trio # type: ignore -import httpx from os import path from http import HTTPStatus from quart import ( @@ -12,11 +11,10 @@ from quart import ( send_from_directory, url_for, ) -from lnurl import LnurlResponse, LnurlWithdrawResponse, decode as decode_lnurl # type: ignore from lnbits.core import core_app, db from lnbits.decorators import check_user_exists, validate_uuids -from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE +from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE, LNBITS_SITE_TITLE from ..crud import ( create_account, @@ -24,8 +22,10 @@ from ..crud import ( update_user_extension, create_wallet, delete_wallet, + get_balance_check, + save_balance_notify, ) -from ..services import redeem_lnurl_withdraw +from ..services import redeem_lnurl_withdraw, pay_invoice @core_app.route("/favicon.ico") @@ -108,6 +108,62 @@ async def wallet(): ) +@core_app.route("/withdraw") +@validate_uuids(["usr", "wal"], required=True) +async def lnurl_full_withdraw(): + user = await get_user(request.args.get("usr")) + if not user: + return jsonify({"status": "ERROR", "reason": "User does not exist."}) + + wallet = user.get_wallet(request.args.get("wal")) + if not wallet: + return jsonify({"status": "ERROR", "reason": "Wallet does not exist."}) + + return jsonify( + { + "tag": "withdrawRequest", + "callback": url_for( + "core.lnurl_full_withdraw_callback", + usr=user.id, + wal=wallet.id, + _external=True, + ), + "k1": "0", + "minWithdrawable": 1 if wallet.withdrawable_balance else 0, + "maxWithdrawable": wallet.withdrawable_balance, + "defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}", + "balanceCheck": url_for( + "core.lnurl_full_withdraw", usr=user.id, wal=wallet.id, _external=True + ), + } + ) + + +@core_app.route("/withdraw/cb") +@validate_uuids(["usr", "wal"], required=True) +async def lnurl_full_withdraw_callback(): + user = await get_user(request.args.get("usr")) + if not user: + return jsonify({"status": "ERROR", "reason": "User does not exist."}) + + wallet = user.get_wallet(request.args.get("wal")) + if not wallet: + return jsonify({"status": "ERROR", "reason": "Wallet does not exist."}) + + pr = request.args.get("pr") + + async def pay(): + await pay_invoice(wallet_id=wallet.id, payment_request=pr) + + g.nursery.start_soon(pay) + + balance_notify = request.args.get("balanceNotify") + if balance_notify: + await save_balance_notify(wallet.id, balance_notify) + + return jsonify({"status": "OK"}) + + @core_app.route("/deletewallet") @validate_uuids(["usr", "wal"], required=True) @check_user_exists() @@ -127,31 +183,16 @@ async def deletewallet(): return redirect(url_for("core.home")) +@core_app.route("/withdraw/notify/") +@validate_uuids(["wal"], required=True) +async def lnurl_balance_notify(service: str): + bc = await get_balance_check(request.args.get("wal"), service) + if bc: + redeem_lnurl_withdraw(bc.wallet, bc.url) + + @core_app.route("/lnurlwallet") async def lnurlwallet(): - async with httpx.AsyncClient() as client: - try: - lnurl = decode_lnurl(request.args.get("lightning")) - r = await client.get(str(lnurl)) - withdraw_res = LnurlResponse.from_dict(r.json()) - - if not withdraw_res.ok: - return ( - f"Could not process lnurl-withdraw: {withdraw_res.error_msg}", - HTTPStatus.BAD_REQUEST, - ) - - if not isinstance(withdraw_res, LnurlWithdrawResponse): - return ( - f"Expected an lnurl-withdraw code, got {withdraw_res.tag}", - HTTPStatus.BAD_REQUEST, - ) - except Exception as exc: - return ( - f"Could not process lnurl-withdraw: {exc}", - HTTPStatus.INTERNAL_SERVER_ERROR, - ) - async with db.connect() as conn: account = await create_account(conn=conn) user = await get_user(account.id, conn=conn) @@ -160,7 +201,7 @@ async def lnurlwallet(): g.nursery.start_soon( redeem_lnurl_withdraw, wallet.id, - withdraw_res, + request.args.get("lightning"), "LNbits initial funding: voucher redeem.", ) await trio.sleep(3) diff --git a/lnbits/tasks.py b/lnbits/tasks.py index d8f26a75..0e2ff98d 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -9,7 +9,9 @@ from lnbits.core.crud import ( get_payments, get_standalone_payment, delete_expired_invoices, + get_balance_checks, ) +from lnbits.core.services import redeem_lnurl_withdraw main_app: Optional[QuartTrio] = None @@ -93,6 +95,14 @@ async def check_pending_payments(): await trio.sleep(60 * 30) # every 30 minutes +async def perform_balance_checks(): + while True: + for bc in await get_balance_checks(): + redeem_lnurl_withdraw(bc.wallet, bc.url) + + await trio.sleep(60 * 60 * 6) # every 6 hours + + async def invoice_callback_dispatcher(checking_id: str): payment = await get_standalone_payment(checking_id) if payment and payment.is_in: From 943237e7ff39da26c6f4265617e9ae37e855b237 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sat, 17 Apr 2021 23:21:27 -0300 Subject: [PATCH 4/9] prettier fixes. --- lnbits/core/templates/core/wallet.html | 2 +- .../extensions/captcha/static/js/captcha.js | 10 ++++----- .../templates/livestream/index.html | 21 ++++++++++--------- lnbits/templates/base.html | 4 ++-- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 81bd9c66..39c519af 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -4,7 +4,7 @@ {% block scripts %} {{ window_vars(user, wallet) }} - + {% endblock %} {% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %} diff --git a/lnbits/extensions/captcha/static/js/captcha.js b/lnbits/extensions/captcha/static/js/captcha.js index 0c6fb1de..1da24f57 100644 --- a/lnbits/extensions/captcha/static/js/captcha.js +++ b/lnbits/extensions/captcha/static/js/captcha.js @@ -2,7 +2,7 @@ var ciframeLoaded = !1, captchaStyleAdded = !1 function ccreateIframeElement(t = {}) { - const e = document.createElement('iframe') + const e = document.createElement('iframe') // e.style.marginLeft = "25px", ;(e.style.border = 'none'), (e.style.width = '100%'), @@ -12,11 +12,9 @@ function ccreateIframeElement(t = {}) { t.dest, t.amount, t.currency, t.label, t.opReturn var captchaid = document .getElementById('captchascript') - .getAttribute('data-captchaid'); - var lnbhostsrc = document - .getElementById('captchascript') - .getAttribute('src'); - var lnbhost = lnbhostsrc.split("/captcha/static/js/captcha.js")[0]; + .getAttribute('data-captchaid') + var lnbhostsrc = document.getElementById('captchascript').getAttribute('src') + var lnbhost = lnbhostsrc.split('/captcha/static/js/captcha.js')[0] return (e.src = lnbhost + '/captcha/' + captchaid), e } document.addEventListener('DOMContentLoaded', function () { diff --git a/lnbits/extensions/livestream/templates/livestream/index.html b/lnbits/extensions/livestream/templates/livestream/index.html index f242744a..40ce19cd 100644 --- a/lnbits/extensions/livestream/templates/livestream/index.html +++ b/lnbits/extensions/livestream/templates/livestream/index.html @@ -61,10 +61,7 @@

Tracks
- Add new track
@@ -229,8 +226,13 @@ - -

Standalone QR Code for this track

+ +

+ Standalone QR Code for this track +

- Update track - Add track - + Update track + Add track +
- - + + {% block head_scripts %}{% endblock %} From a41368516057577e4b392cd6d6f06816ca2ffaf6 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sat, 17 Apr 2021 23:30:26 -0300 Subject: [PATCH 5/9] fix redeem_lnurl_withdraw code. --- lnbits/core/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 39b2eaed..8ccf1a42 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -193,7 +193,7 @@ async def redeem_lnurl_withdraw( _, payment_request = await create_invoice( wallet_id=wallet_id, - amount=res["maxWithdrawable"], + amount=int(res["maxWithdrawable"] / 1000), memo=memo or res["defaultDescription"] or "", extra=extra, conn=conn, @@ -208,7 +208,7 @@ async def redeem_lnurl_withdraw( "balanceNotify": url_for( "core.lnurl_balance_notify", service=urlparse(lnurl_request).netloc, - wal=g.wallet.id, + wal=wallet_id, _external=True, ), }, From cbeb888761fd67c54b89380a0c4ce987caff7fe0 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sat, 17 Apr 2021 23:44:26 -0300 Subject: [PATCH 6/9] fix lnurlwallet flow. --- lnbits/core/services.py | 31 +++++++++++++++++++++---------- lnbits/core/views/generic.py | 4 ++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 8ccf1a42..97435f72 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -1,3 +1,4 @@ +import trio # type: ignore import json import httpx from io import BytesIO @@ -182,6 +183,7 @@ async def redeem_lnurl_withdraw( lnurl_request: str, memo: Optional[str] = None, extra: Optional[Dict] = None, + wait_seconds: int = 0, conn: Optional[Connection] = None, ) -> None: res = {} @@ -199,19 +201,28 @@ async def redeem_lnurl_withdraw( conn=conn, ) + if wait_seconds: + await trio.sleep(wait_seconds) + + params = { + "k1": res["k1"], + "pr": payment_request, + } + + try: + params["balanceNotify"] = url_for( + "core.lnurl_balance_notify", + service=urlparse(lnurl_request).netloc, + wal=wallet_id, + _external=True, + ) + except Exception: + pass + async with httpx.AsyncClient() as client: await client.get( res["callback"], - params={ - "k1": res["k1"], - "pr": payment_request, - "balanceNotify": url_for( - "core.lnurl_balance_notify", - service=urlparse(lnurl_request).netloc, - wal=wallet_id, - _external=True, - ), - }, + params=params, ) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 38451189..e17d71b3 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -1,4 +1,3 @@ -import trio # type: ignore from os import path from http import HTTPStatus from quart import ( @@ -203,8 +202,9 @@ async def lnurlwallet(): wallet.id, request.args.get("lightning"), "LNbits initial funding: voucher redeem.", + {"tag": "lnurlwallet"}, + 5, # wait 5 seconds before sending the invoice to the service ) - await trio.sleep(3) return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id)) From 42b3359d1283941d4c44e1e9cb5037d96e6618c0 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 18 Apr 2021 00:05:45 -0300 Subject: [PATCH 7/9] fix invoice listener for LNbitsWallet. --- lnbits/wallets/lnbits.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py index 786a4b03..5ac3e245 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -125,7 +125,7 @@ class LNbitsWallet(Wallet): except json.decoder.JSONDecodeError: continue - if type(data) is not list or len(data) < 9: + if type(data) is not dict: continue - yield data[8] # payment_hash + yield data["payment_hash"] # payment_hash From 3d489bf2eeae8f2dbdc5518fb9eec93da918b4e2 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 18 Apr 2021 10:29:22 -0300 Subject: [PATCH 8/9] make it so LNbitsWallet reconnects if the listener stream goes off. --- lnbits/wallets/lnbits.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py index 5ac3e245..13ea8046 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -1,3 +1,4 @@ +import trio # type: ignore import json import httpx from os import getenv @@ -116,16 +117,25 @@ class LNbitsWallet(Wallet): async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: url = f"{self.endpoint}/api/v1/payments/sse" - async with httpx.AsyncClient(timeout=None, headers=self.key) as client: - async with client.stream("GET", url) as r: - async for line in r.aiter_lines(): - if line.startswith("data:"): - try: - data = json.loads(line[5:]) - except json.decoder.JSONDecodeError: - continue + while True: + try: + async with httpx.AsyncClient(timeout=None, headers=self.key) as client: + async with client.stream("GET", url) as r: + async for line in r.aiter_lines(): + if line.startswith("data:"): - if type(data) is not dict: - continue + try: + data = json.loads(line[5:]) + except json.decoder.JSONDecodeError: + continue - yield data["payment_hash"] # payment_hash + if type(data) is not dict: + continue + + yield data["payment_hash"] # payment_hash + + except (OSError, httpx.ReadError, httpx.ConnectError): + pass + + print("lost connection to lnbits /payments/sse, retrying in 5 seconds") + await trio.sleep(5) From ffe39de391c1a6585f8b7b7660866f344f51e34c Mon Sep 17 00:00:00 2001 From: Pac Date: Sat, 17 Apr 2021 22:56:13 -0300 Subject: [PATCH 9/9] API link fix for List Links docs (from v0 to v1) --- lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html index 8aff693f..68cea8f8 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html @@ -17,7 +17,7 @@ [<pay_link_object>, ...]
Curl example
curl -X GET {{ request.url_root }}api/v0/links -H "X-Api-Key: {{ + >curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{ g.user.wallets[0].inkey }}"