mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-03-03 17:37:06 +01:00
lnurl balanceCheck and balanceNotify.
This commit is contained in:
parent
f08d86c6df
commit
efd9c6917f
9 changed files with 303 additions and 43 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -231,13 +231,39 @@
|
|||
<q-list>
|
||||
{% include "core/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="crop_free"
|
||||
label="Drain Funds"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This is an LNURL-withdraw QR code for slurping everything from
|
||||
this wallet. Do not share with anyone.
|
||||
</p>
|
||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||
<qrcode
|
||||
value="{{wallet.lnurlwithdraw_full}}"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</a>
|
||||
<p>
|
||||
It is compatible with <code>balanceCheck</code> and
|
||||
<code>balanceNotify</code> so your wallet may keep pulling the
|
||||
funds continuously from here after the first withdraw.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="settings_cell"
|
||||
label="Export to Phone with QR Code"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This QR code contains your wallet URL with full access. You
|
||||
can scan it from your phone to open your wallet from there.
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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/<service>")
|
||||
@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)
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Reference in a new issue