lnurl balanceCheck and balanceNotify.

This commit is contained in:
fiatjaf 2021-04-17 18:27:15 -03:00
parent f08d86c6df
commit efd9c6917f
9 changed files with 303 additions and 43 deletions

View file

@ -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

View file

@ -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)
);
"""
)

View file

@ -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"])

View file

@ -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))

View file

@ -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:

View file

@ -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.

View file

@ -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))

View file

@ -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)

View file

@ -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: