diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index f3abb75ff..b9f02070f 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 0f14d9df5..64de9acf1 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 d0b648c1e..b2cd70afd 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 28203c58f..39b2eaed4 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 763ef9988..20740c05d 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 f1dd41736..81bd9c66d 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 7c8cf8b9a..89330ab34 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 495d16aee..384511897 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 d8f26a757..0e2ff98da 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: