diff --git a/lnbits/extensions/lndhub/README.md b/lnbits/extensions/lndhub/README.md new file mode 100644 index 000000000..f567d5492 --- /dev/null +++ b/lnbits/extensions/lndhub/README.md @@ -0,0 +1,6 @@ +

lndhub Extension

+

*connect to your lnbits wallet from BlueWallet or Zeus*

+ +Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/. + +Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club. diff --git a/lnbits/extensions/lndhub/__init__.py b/lnbits/extensions/lndhub/__init__.py new file mode 100644 index 000000000..13d9069c6 --- /dev/null +++ b/lnbits/extensions/lndhub/__init__.py @@ -0,0 +1,25 @@ +import asyncio +from fastapi import APIRouter, FastAPI +from fastapi.staticfiles import StaticFiles +from starlette.routing import Mount +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_lndhub") + +lndhub_ext: APIRouter = APIRouter(prefix="/lndhub", tags=["lndhub"]) + + +def lndhub_renderer(): + return template_renderer( + [ + "lnbits/extensions/lndhub/templates", + ] + ) + + +from .views_api import * # noqa +from .views import * # noqa +from .utils import * # noqa +from .decorators import * # noqa diff --git a/lnbits/extensions/lndhub/config.json b/lnbits/extensions/lndhub/config.json new file mode 100644 index 000000000..6285ff80d --- /dev/null +++ b/lnbits/extensions/lndhub/config.json @@ -0,0 +1,6 @@ +{ + "name": "LndHub", + "short_description": "Access lnbits from BlueWallet or Zeus", + "icon": "navigation", + "contributors": ["fiatjaf"] +} diff --git a/lnbits/extensions/lndhub/decorators.py b/lnbits/extensions/lndhub/decorators.py new file mode 100644 index 000000000..7526135ba --- /dev/null +++ b/lnbits/extensions/lndhub/decorators.py @@ -0,0 +1,33 @@ +from base64 import b64decode +from functools import wraps + +from lnbits.core.crud import get_wallet_for_key +from fastapi import Request +from http import HTTPStatus +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse, JSONResponse # type: ignore + + +def check_wallet(requires_admin=False): + def wrap(view): + @wraps(view) + async def wrapped_view(request: Request, **kwargs): + token = request.headers["Authorization"].split("Bearer ")[1] + key_type, key = b64decode(token).decode("utf-8").split(":") + + if requires_admin and key_type != "admin": + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="insufficient permissions", + ) + g.wallet = await get_wallet_for_key(key, key_type) + if not g.wallet: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="insufficient permissions", + ) + return await view(**kwargs) + + return wrapped_view + + return wrap diff --git a/lnbits/extensions/lndhub/migrations.py b/lnbits/extensions/lndhub/migrations.py new file mode 100644 index 000000000..d6ea5fdea --- /dev/null +++ b/lnbits/extensions/lndhub/migrations.py @@ -0,0 +1,2 @@ +async def migrate(): + pass diff --git a/lnbits/extensions/lndhub/templates/lndhub/_instructions.html b/lnbits/extensions/lndhub/templates/lndhub/_instructions.html new file mode 100644 index 000000000..4db79aba8 --- /dev/null +++ b/lnbits/extensions/lndhub/templates/lndhub/_instructions.html @@ -0,0 +1,35 @@ + + + + To access an LNbits wallet from a mobile phone, +
    +
  1. + Install either Zeus or + BlueWallet; +
  2. +
  3. + Go to Add a wallet / Import wallet on BlueWallet or + Settings / Add a new node on Zeus. +
  4. +
  5. Select the desired wallet on this page;
  6. +
  7. Scan one of the two QR codes from the mobile wallet.
  8. +
+
    +
  • + Invoice URLs mean the mobile wallet will only have the + authorization to read your payments and invoices and generate new + invoices. +
  • +
  • + Admin URLs mean the mobile wallet will be able to pay + invoices.. +
  • +
+
+
+
diff --git a/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html b/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html new file mode 100644 index 000000000..a15cab8fa --- /dev/null +++ b/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html @@ -0,0 +1,19 @@ + + + +

+ LndHub is a protocol invented by + BlueWallet that allows mobile + wallets to query payments and balances, generate invoices and make + payments from accounts that exist on a server. The protocol is a + collection of HTTP endpoints exposed through the internet. +

+

+ For a wallet that supports it, reading a QR code that contains the URL + along with secret access credentials should enable access. Currently it + is supported by Zeus and + BlueWallet. +

+
+
+
diff --git a/lnbits/extensions/lndhub/templates/lndhub/index.html b/lnbits/extensions/lndhub/templates/lndhub/index.html new file mode 100644 index 000000000..2c282e59c --- /dev/null +++ b/lnbits/extensions/lndhub/templates/lndhub/index.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} {% raw %} +
+
+
+ + + +
+ Copy LndHub {{type}} URL +
+
+
+
+ + + + + + + +
+ + {% endraw %} + +
+ + +
+ {{SITE_TITLE}} LndHub extension +
+
+ + + + {% include "lndhub/_instructions.html" %} + + {% include "lndhub/_lndhub.html" %} + + +
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/lndhub/utils.py b/lnbits/extensions/lndhub/utils.py new file mode 100644 index 000000000..3db6317a7 --- /dev/null +++ b/lnbits/extensions/lndhub/utils.py @@ -0,0 +1,21 @@ +from binascii import unhexlify + +from lnbits.bolt11 import Invoice + + +def to_buffer(payment_hash: str): + return {"type": "Buffer", "data": [b for b in unhexlify(payment_hash)]} + + +def decoded_as_lndhub(invoice: Invoice): + return { + "destination": invoice.payee, + "payment_hash": invoice.payment_hash, + "num_satoshis": invoice.amount_msat / 1000, + "timestamp": str(invoice.date), + "expiry": str(invoice.expiry), + "description": invoice.description, + "fallback_addr": "", + "cltv_expiry": invoice.min_final_cltv_expiry, + "route_hints": "", + } diff --git a/lnbits/extensions/lndhub/views.py b/lnbits/extensions/lndhub/views.py new file mode 100644 index 000000000..275c692e4 --- /dev/null +++ b/lnbits/extensions/lndhub/views.py @@ -0,0 +1,12 @@ +from lnbits.decorators import check_user_exists +from . import lndhub_ext, lndhub_renderer +from fastapi import FastAPI, Request +from fastapi.params import Depends +from lnbits.core.models import User + + +@lndhub_ext.get("/") +async def lndhub_index(request: Request, user: User = Depends(check_user_exists)): + return lndhub_renderer().TemplateResponse( + "lndhub/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py new file mode 100644 index 000000000..0ec7cd391 --- /dev/null +++ b/lnbits/extensions/lndhub/views_api.py @@ -0,0 +1,218 @@ +import time +from base64 import urlsafe_b64encode + +from lnbits.core.services import pay_invoice, create_invoice +from lnbits.core.crud import get_payments, delete_expired_invoices +from lnbits.decorators import api_validate_post_request, WalletTypeInfo, get_key_type +from lnbits.settings import WALLET +from lnbits import bolt11 + +from . import lndhub_ext +from .decorators import check_wallet +from .utils import to_buffer, decoded_as_lndhub +from http import HTTPStatus +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse, JSONResponse # type: ignore +from fastapi.params import Depends +from fastapi.param_functions import Query + + +@lndhub_ext.get("/ext/getinfo") +async def lndhub_getinfo(): + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="bad auth", + ) + + +@lndhub_ext.post("/ext/auth") +async def lndhub_auth( + login: str = Query(None), + password: str = Query(None), + refresh_token: str = Query(None), +): + token = ( + refresh_token + if refresh_token + else urlsafe_b64encode((login + ":" + password).encode("utf-8")).decode("ascii") + ) + return {"refresh_token": token, "access_token": token} + + +@lndhub_ext.post("/ext/addinvoice") +async def lndhub_addinvoice( + wallet: WalletTypeInfo = Depends(get_key_type), + amt: str = Query(None), + memo: str = Query(None), + preimage: str = Query(None), +): + try: + _, pr = await create_invoice( + wallet_id=wallet.wallet.id, + amount=int(amt), + memo=memo, + extra={"tag": "lndhub"}, + ) + except: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Failed to create invoice", + ) + + invoice = bolt11.decode(pr) + return { + "pay_req": pr, + "payment_request": pr, + "add_index": "500", + "r_hash": to_buffer(invoice.payment_hash), + "hash": invoice.payment_hash, + } + + +@lndhub_ext.post("/ext/payinvoice") +async def lndhub_payinvoice( + wallet: WalletTypeInfo = Depends(get_key_type), invoice: str = Query(None) +): + try: + await pay_invoice( + wallet_id=wallet.wallet.id, + payment_request=invoice, + extra={"tag": "lndhub"}, + ) + except: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="FPayment failed", + ) + + invoice: bolt11.Invoice = bolt11.decode(invoice) + return { + "payment_error": "", + "payment_preimage": "0" * 64, + "route": {}, + "payment_hash": invoice.payment_hash, + "decoded": decoded_as_lndhub(invoice), + "fee_msat": 0, + "type": "paid_invoice", + "fee": 0, + "value": invoice.amount_msat / 1000, + "timestamp": int(time.time()), + "memo": invoice.description, + } + + +@lndhub_ext.get("/ext/balance") +@check_wallet() +async def lndhub_balance( + wallet: WalletTypeInfo = Depends(get_key_type), +): + return {"BTC": {"AvailableBalance": wallet.wallet.balance}} + + +@lndhub_ext.route("/ext/gettxs", methods=["GET"]) +@check_wallet() +async def lndhub_gettxs( + wallet: WalletTypeInfo = Depends(get_key_type), limit: int = Query(0, ge=0, lt=200) +): + for payment in await get_payments( + wallet_id=wallet.wallet.id, + complete=False, + pending=True, + outgoing=True, + incoming=False, + exclude_uncheckable=True, + ): + await payment.set_pending( + (await WALLET.get_payment_status(payment.checking_id)).pending + ) + return [ + { + "payment_preimage": payment.preimage, + "payment_hash": payment.payment_hash, + "fee_msat": payment.fee * 1000, + "type": "paid_invoice", + "fee": payment.fee, + "value": int(payment.amount / 1000), + "timestamp": payment.time, + "memo": payment.memo if not payment.pending else "Payment in transition", + } + for payment in reversed( + ( + await get_payments( + wallet_id=wallet.wallet.id, + pending=True, + complete=True, + outgoing=True, + incoming=False, + ) + )[:limit] + ) + ] + + +@lndhub_ext.route("/ext/getuserinvoices", methods=["GET"]) +async def lndhub_getuserinvoices( + wallet: WalletTypeInfo = Depends(get_key_type), limit: int = Query(0, ge=0, lt=200) +): + await delete_expired_invoices() + for invoice in await get_payments( + wallet_id=wallet.wallet.id, + complete=False, + pending=True, + outgoing=False, + incoming=True, + exclude_uncheckable=True, + ): + await invoice.set_pending( + (await WALLET.get_invoice_status(invoice.checking_id)).pending + ) + return [ + { + "r_hash": to_buffer(invoice.payment_hash), + "payment_request": invoice.bolt11, + "add_index": "500", + "description": invoice.memo, + "payment_hash": invoice.payment_hash, + "ispaid": not invoice.pending, + "amt": int(invoice.amount / 1000), + "expire_time": int(time.time() + 1800), + "timestamp": invoice.time, + "type": "user_invoice", + } + for invoice in reversed( + ( + await get_payments( + wallet_id=wallet.wallet.id, + pending=True, + complete=True, + incoming=True, + outgoing=False, + ) + )[:limit] + ) + ] + + +@lndhub_ext.get("/ext/getbtc") +async def lndhub_getbtc(wallet: WalletTypeInfo = Depends(get_key_type)): + "load an address for incoming onchain btc" + return [] + + +@lndhub_ext.get("/ext/getpending") +@check_wallet() +async def lndhub_getpending(): + "pending onchain transactions" + return [] + + +@lndhub_ext.get("/ext/decodeinvoice") +async def lndhub_decodeinvoice(invoice: str = Query(None)): + inv = bolt11.decode(invoice) + return decoded_as_lndhub(inv) + + +@lndhub_ext.get("/ext/checkrouteinvoice") +async def lndhub_checkrouteinvoice(): + "not implemented on canonical lndhub" + pass