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,
+
+ -
+ Install either Zeus or
+ BlueWallet;
+
+ -
+ Go to
Add a wallet / Import wallet
on BlueWallet or
+ Settings / Add a new node
on Zeus.
+
+ - Select the desired wallet on this page;
+ - Scan one of the two QR codes from the mobile wallet.
+
+
+ -
+ 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