+ 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..e17d71b3 100644
--- a/lnbits/core/views/generic.py
+++ b/lnbits/core/views/generic.py
@@ -1,5 +1,3 @@
-import trio # type: ignore
-import httpx
from os import path
from http import HTTPStatus
from quart import (
@@ -12,11 +10,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 +21,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 +107,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 +182,16 @@ async def deletewallet():
return redirect(url_for("core.home"))
+@core_app.route("/withdraw/notify/
+ Standalone QR Code for this track
+ Tracks
[<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 }}"
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:
diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html
index 59016e99..6f2246da 100644
--- a/lnbits/templates/base.html
+++ b/lnbits/templates/base.html
@@ -14,8 +14,8 @@
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
-
-
+
+
{% block head_scripts %}{% endblock %}
diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py
index 786a4b03..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 list or len(data) < 9:
- continue
+ try:
+ data = json.loads(line[5:])
+ except json.decoder.JSONDecodeError:
+ continue
- yield data[8] # 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)