diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index c919821ff..7d91038f6 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -466,11 +466,11 @@ async def api_lnurlscan(code: str): @core_app.post("/api/v1/payments/decode") async def api_payments_decode(data: str = Query(None)): try: - if g.data["data"][:5] == "LNURL": - url = lnurl.decode(g.data["data"]) + if data["data"][:5] == "LNURL": + url = lnurl.decode(data["data"]) return {"domain": url} else: - invoice = bolt11.decode(g.data["data"]) + invoice = bolt11.decode(data["data"]) return { "payment_hash": invoice.payment_hash, "amount_msat": invoice.amount_msat, diff --git a/lnbits/extensions/lnurlpayout/README.md b/lnbits/extensions/lnurlpayout/README.md new file mode 100644 index 000000000..ddf209fe4 --- /dev/null +++ b/lnbits/extensions/lnurlpayout/README.md @@ -0,0 +1,3 @@ +# LNURLPayOut + +## Auto-dump a wallets funds to an LNURLpay diff --git a/lnbits/extensions/lnurlpayout/__init__.py b/lnbits/extensions/lnurlpayout/__init__.py new file mode 100644 index 000000000..ad14dd370 --- /dev/null +++ b/lnbits/extensions/lnurlpayout/__init__.py @@ -0,0 +1,22 @@ +import asyncio +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_lnurlpayout") + +lnurlpayout_ext: APIRouter = APIRouter(prefix="/lnurlpayout", tags=["lnurlpayout"]) + + +def lnurlpayout_renderer(): + return template_renderer(["lnbits/extensions/lnurlpayout/templates"]) + +from .tasks import wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa + +def lnurlpayout_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/lnurlpayout/config.json b/lnbits/extensions/lnurlpayout/config.json new file mode 100644 index 000000000..1e72c0c1e --- /dev/null +++ b/lnbits/extensions/lnurlpayout/config.json @@ -0,0 +1,6 @@ +{ + "name": "LNURLPayout", + "short_description": "Autodump wallet funds to LNURLpay", + "icon": "exit_to_app", + "contributors": ["arcbtc"] +} diff --git a/lnbits/extensions/lnurlpayout/crud.py b/lnbits/extensions/lnurlpayout/crud.py new file mode 100644 index 000000000..4a09a2230 --- /dev/null +++ b/lnbits/extensions/lnurlpayout/crud.py @@ -0,0 +1,45 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import lnurlpayout, CreateLnurlPayoutData + + +async def create_lnurlpayout(wallet_id: str, admin_key: str, data: CreateLnurlPayoutData) -> lnurlpayout: + lnurlpayout_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO lnurlpayout.lnurlpayouts (id, title, wallet, admin_key, lnurlpay, threshold) + VALUES (?, ?, ?, ?, ?, ?) + """, + (lnurlpayout_id, data.title, wallet_id, admin_key, data.lnurlpay, data.threshold), + ) + + lnurlpayout = await get_lnurlpayout(lnurlpayout_id) + assert lnurlpayout, "Newly created lnurlpayout couldn't be retrieved" + return lnurlpayout + + +async def get_lnurlpayout(lnurlpayout_id: str) -> Optional[lnurlpayout]: + row = await db.fetchone("SELECT * FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,)) + return lnurlpayout(**row) if row else None + +async def get_lnurlpayout_from_wallet(wallet_id: str) -> Optional[lnurlpayout]: + row = await db.fetchone("SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet = ?", (wallet_id,)) + return lnurlpayout(**row) if row else None + +async def get_lnurlpayouts(wallet_ids: Union[str, List[str]]) -> List[lnurlpayout]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [lnurlpayout(**row) if row else None for row in rows] + + +async def delete_lnurlpayout(lnurlpayout_id: str) -> None: + await db.execute("DELETE FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,)) diff --git a/lnbits/extensions/lnurlpayout/migrations.py b/lnbits/extensions/lnurlpayout/migrations.py new file mode 100644 index 000000000..6af047910 --- /dev/null +++ b/lnbits/extensions/lnurlpayout/migrations.py @@ -0,0 +1,16 @@ +async def m001_initial(db): + """ + Initial lnurlpayouts table. + """ + await db.execute( + """ + CREATE TABLE lnurlpayout.lnurlpayouts ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + wallet TEXT NOT NULL, + admin_key TEXT NOT NULL, + lnurlpay TEXT NOT NULL, + threshold INT NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/lnurlpayout/models.py b/lnbits/extensions/lnurlpayout/models.py new file mode 100644 index 000000000..3d6a14011 --- /dev/null +++ b/lnbits/extensions/lnurlpayout/models.py @@ -0,0 +1,16 @@ +from sqlite3 import Row + +from pydantic import BaseModel + +class CreateLnurlPayoutData(BaseModel): + title: str + lnurlpay: str + threshold: int + +class lnurlpayout(BaseModel): + id: str + title: str + wallet: str + admin_key: str + lnurlpay: str + threshold: int diff --git a/lnbits/extensions/lnurlpayout/tasks.py b/lnbits/extensions/lnurlpayout/tasks.py new file mode 100644 index 000000000..deb287382 --- /dev/null +++ b/lnbits/extensions/lnurlpayout/tasks.py @@ -0,0 +1,70 @@ +import asyncio +import json +import httpx + +from lnbits.core import db as core_db +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener +from lnbits.core.views.api import api_wallet +from lnbits.core.crud import get_wallet +from lnbits.core.views.api import api_payment, api_payments_decode, pay_invoice + +from .crud import get_lnurlpayout, get_lnurlpayout_from_wallet + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + try: + # Check its got a payout associated with it + lnurlpayout_link = await get_lnurlpayout_from_wallet(payment.wallet_id) + if lnurlpayout_link: + + # Check the wallet balance is more than the threshold + + wallet = await get_wallet(lnurlpayout_link.wallet) + if wallet.balance < lnurlpayout_link.threshold + (lnurlpayout_link.threshold*0.02): + return + + # Get the invoice from the LNURL to pay + async with httpx.AsyncClient() as client: + try: + url = await api_payments_decode({"data": lnurlpayout_link.lnurlpay}) + if str(url["domain"])[0:4] != "http": + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="LNURL broken") + try: + r = await client.get( + str(url["domain"]), + timeout=40, + ) + res = r.json() + try: + r = await client.get( + res["callback"] + "?amount=" + str(int((wallet.balance - wallet.balance*0.02) * 1000)), + timeout=40, + ) + res = r.json() + try: + await pay_invoice( + wallet_id=payment.wallet_id, + payment_request=res["pr"], + extra={"tag": "lnurlpayout"}, + ) + return + except: + pass + except: + return + except (httpx.ConnectError, httpx.RequestError): + return + except Exception: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Failed to save LNURLPayout") + except: + return \ No newline at end of file diff --git a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html new file mode 100644 index 000000000..7febea44c --- /dev/null +++ b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html @@ -0,0 +1,118 @@ + + + + + GET + /lnurlpayout/api/v1/lnurlpayouts +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<lnurlpayout_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + POST + /lnurlpayout/api/v1/lnurlpayouts +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+ {"name": <string>, "currency": <string*ie USD*>} +
+ Returns 201 CREATED (application/json) +
+ {"currency": <string>, "id": <string>, "name": + <string>, "wallet": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -d + '{"name": <string>, "currency": <string>}' -H + "Content-type: application/json" -H "X-Api-Key: <admin_key>" + +
+
+
+ + + + + DELETE + /lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.base_url + }}lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -H + "X-Api-Key: <admin_key>" + +
+
+
+ + + + GET + /lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<lnurlpayout_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url + }}lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -H + "X-Api-Key: <invoice_key>" + +
+
+
+
diff --git a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/index.html b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/index.html new file mode 100644 index 000000000..ad43add15 --- /dev/null +++ b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/index.html @@ -0,0 +1,268 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New LNURLPayout + + + + + +
+
+
LNURLPayout
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} LNURLPayout extension +
+
+ + + + {% include "lnurlpayout/_api_docs.html" %} + + + +
+
+ + + + + + + + +
+ Create LNURLPayout + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/lnurlpayout/views.py b/lnbits/extensions/lnurlpayout/views.py new file mode 100644 index 000000000..454a33328 --- /dev/null +++ b/lnbits/extensions/lnurlpayout/views.py @@ -0,0 +1,22 @@ +from http import HTTPStatus + +from fastapi import Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import lnurlpayout_ext, lnurlpayout_renderer +from .crud import get_lnurlpayout + +templates = Jinja2Templates(directory="templates") + + +@lnurlpayout_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return lnurlpayout_renderer().TemplateResponse( + "lnurlpayout/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/lnurlpayout/views_api.py b/lnbits/extensions/lnurlpayout/views_api.py new file mode 100644 index 000000000..03cd32828 --- /dev/null +++ b/lnbits/extensions/lnurlpayout/views_api.py @@ -0,0 +1,82 @@ +from http import HTTPStatus + +from fastapi import Query +from fastapi.params import Depends +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user, get_payments +from lnbits.core.services import create_invoice +from lnbits.core.views.api import api_payment, api_payments_decode +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key + +from . import lnurlpayout_ext +from .crud import create_lnurlpayout, delete_lnurlpayout, get_lnurlpayout, get_lnurlpayouts, get_lnurlpayout_from_wallet +from .models import lnurlpayout, CreateLnurlPayoutData +from .tasks import on_invoice_paid + +@lnurlpayout_ext.get("/api/v1/lnurlpayouts", status_code=HTTPStatus.OK) +async def api_lnurlpayouts( + all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) +): + wallet_ids = [wallet.wallet.id] + if all_wallets: + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + + return [lnurlpayout.dict() for lnurlpayout in await get_lnurlpayouts(wallet_ids)] + + +@lnurlpayout_ext.post("/api/v1/lnurlpayouts", status_code=HTTPStatus.CREATED) +async def api_lnurlpayout_create( + data: CreateLnurlPayoutData, wallet: WalletTypeInfo = Depends(get_key_type) +): + if await get_lnurlpayout_from_wallet(wallet.wallet.id): + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Wallet already has lnurlpayout set") + return + url = await api_payments_decode({"data": data.lnurlpay}) + if "domain" not in url: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="LNURL could not be decoded") + return + if str(url["domain"])[0:4] != "http": + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not valid LNURL") + return + lnurlpayout = await create_lnurlpayout(wallet_id=wallet.wallet.id, admin_key=wallet.wallet.adminkey, data=data) + if not lnurlpayout: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Failed to save LNURLPayout") + return + return lnurlpayout.dict() + +@lnurlpayout_ext.delete("/api/v1/lnurlpayouts/{lnurlpayout_id}") +async def api_lnurlpayout_delete( + lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +): + lnurlpayout = await get_lnurlpayout(lnurlpayout_id) + + if not lnurlpayout: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="lnurlpayout does not exist." + ) + + if lnurlpayout.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your lnurlpayout.") + + await delete_lnurlpayout(lnurlpayout_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + +@lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK) +async def api_lnurlpayout_check( + lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(get_key_type) +): + lnurlpayout = await get_lnurlpayout(lnurlpayout_id) + payments = await get_payments( + wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True + ) + result = await on_invoice_paid(payments[0]) + return + + # get payouts func + # lnurlpayouts = await get_lnurlpayouts(wallet_ids) + # for lnurlpayout in lnurlpayouts: + # payments = await get_payments( + # wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True + # ) + # await on_invoice_paid(payments[0]) \ No newline at end of file