diff --git a/lnbits/extensions/lndhub/README.md b/lnbits/extensions/lndhub/README.md new file mode 100644 index 0000000..f567d54 --- /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 0000000..b886dce --- /dev/null +++ b/lnbits/extensions/lndhub/__init__.py @@ -0,0 +1,8 @@ +from flask import Blueprint + + +lndhub_ext: Blueprint = Blueprint("lndhub", __name__, static_folder="static", template_folder="templates") + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/lndhub/config.json b/lnbits/extensions/lndhub/config.json new file mode 100644 index 0000000..584acf0 --- /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 0000000..5fbc033 --- /dev/null +++ b/lnbits/extensions/lndhub/decorators.py @@ -0,0 +1,25 @@ +from base64 import b64decode +from flask import jsonify, g, request +from functools import wraps + +from lnbits.core.crud import get_wallet_for_key + + +def check_wallet(requires_admin=False): + def wrap(view): + @wraps(view) + def wrapped_view(**kwargs): + token = request.headers["Authorization"].split("Bearer ")[1] + key_type, key = b64decode(token).decode("utf-8").split(":") + + if requires_admin and key_type != "admin": + return jsonify({"error": True, "code": 2, "message": "insufficient permissions"}) + + g.wallet = get_wallet_for_key(key, key_type) + if not g.wallet: + return jsonify({"error": True, "code": 2, "message": "insufficient permissions"}) + return 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 0000000..3c90d94 --- /dev/null +++ b/lnbits/extensions/lndhub/migrations.py @@ -0,0 +1,2 @@ +def migrate(): + pass diff --git a/lnbits/extensions/lndhub/templates/lndhub/index.html b/lnbits/extensions/lndhub/templates/lndhub/index.html new file mode 100644 index 0000000..36c8b7b --- /dev/null +++ b/lnbits/extensions/lndhub/templates/lndhub/index.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + + +
+ {% for wallet in g.user.wallets %} + + + lndhub://admin:{{wallet.adminkey}}@{% raw %}{{baseURL}}{% endraw %}/lndhub/ext/ + + + {% endfor %} + +
+
+{% 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 0000000..3db6317 --- /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 0000000..aaf9202 --- /dev/null +++ b/lnbits/extensions/lndhub/views.py @@ -0,0 +1,11 @@ +from flask import render_template + +from lnbits.decorators import check_user_exists, validate_uuids +from lnbits.extensions.lndhub import lndhub_ext + + +@lndhub_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +def lndhub_index(): + return render_template("lndhub/index.html") diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py new file mode 100644 index 0000000..1e9604b --- /dev/null +++ b/lnbits/extensions/lndhub/views_api.py @@ -0,0 +1,186 @@ +import time +from base64 import urlsafe_b64encode +from flask import jsonify, g, request + +from lnbits.core.services import pay_invoice, create_invoice +from lnbits.decorators import api_validate_post_request +from lnbits import bolt11 + +from lnbits.extensions.lndhub import lndhub_ext +from .decorators import check_wallet +from .utils import to_buffer, decoded_as_lndhub + + +@lndhub_ext.route("/ext/getinfo", methods=["GET"]) +def lndhub_getinfo(): + return jsonify({"error": True, "code": 1, "message": "bad auth"}) + + +@lndhub_ext.route("/ext/auth", methods=["POST"]) +@api_validate_post_request( + schema={ + "login": {"type": "string", "required": True, "excludes": "refresh_token"}, + "password": {"type": "string", "required": True, "excludes": "refresh_token"}, + "refresh_token": {"type": "string", "required": True, "excludes": ["login", "password"]}, + } +) +def lndhub_auth(): + token = ( + g.data["token"] + if "token" in g.data and g.data["token"] + else urlsafe_b64encode((g.data["login"] + ":" + g.data["password"]).encode("utf-8")).decode("ascii") + ) + return jsonify({"refresh_token": token, "access_token": token}) + + +@lndhub_ext.route("/ext/addinvoice", methods=["POST"]) +@check_wallet() +@api_validate_post_request( + schema={ + "amt": {"type": "string", "required": True}, + "memo": {"type": "string", "required": True}, + "preimage": {"type": "string", "required": False}, + } +) +def lndhub_addinvoice(): + try: + _, pr = create_invoice( + wallet_id=g.wallet.id, + amount=int(g.data["amt"]), + memo=g.data["memo"], + ) + except Exception as e: + return jsonify( + { + "error": True, + "code": 7, + "message": "Failed to create invoice: " + str(e), + } + ) + + invoice = bolt11.decode(pr) + return jsonify( + { + "pay_req": pr, + "payment_request": pr, + "add_index": "500", + "r_hash": to_buffer(invoice.payment_hash), + "hash": invoice.payment_hash, + } + ) + + +@lndhub_ext.route("/ext/payinvoice", methods=["POST"]) +@check_wallet(requires_admin=True) +@api_validate_post_request(schema={"invoice": {"type": "string", "required": True}}) +def lndhub_payinvoice(): + try: + pay_invoice(wallet_id=g.wallet.id, payment_request=g.data["invoice"]) + except Exception as e: + return jsonify( + { + "error": True, + "code": 10, + "message": "Payment failed: " + str(e), + } + ) + + invoice: bolt11.Invoice = bolt11.decode(g.data["invoice"]) + return jsonify( + { + "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.route("/ext/balance", methods=["GET"]) +@check_wallet() +def lndhub_balance(): + return jsonify({"BTC": {"AvailableBalance": g.wallet.balance}}) + + +@lndhub_ext.route("/ext/gettxs", methods=["GET"]) +@check_wallet() +def lndhub_gettxs(): + limit = int(request.args.get("limit", 200)) + + return jsonify( + [ + { + "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 g.wallet.get_payments( + pending=True, complete=True, outgoing=True, incoming=False, order="ASC" + )[0:limit] + ] + ) + + +@lndhub_ext.route("/ext/getuserinvoices", methods=["GET"]) +@check_wallet() +def lndhub_getuserinvoices(): + limit = int(request.args.get("limit", 200)) + + return jsonify( + [ + { + "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 g.wallet.get_payments( + pending=True, complete=True, incoming=True, outgoing=False, order="ASC" + )[:limit] + ] + ) + + +@lndhub_ext.route("/ext/getbtc", methods=["GET"]) +@check_wallet() +def lndhub_getbtc(): + "load an address for incoming onchain btc" + return jsonify([]) + + +@lndhub_ext.route("/ext/getpending", methods=["GET"]) +@check_wallet() +def lndhub_getpending(): + "pending onchain transactions" + return jsonify([]) + + +@lndhub_ext.route("/ext/decodeinvoice", methods=["GET"]) +def lndhub_decodeinvoice(): + invoice = request.args.get("invoice") + inv = bolt11.decode(invoice) + return jsonify(decoded_as_lndhub(inv)) + + +@lndhub_ext.route("/ext/checkrouteinvoice", methods=["GET"]) +def lndhub_checkrouteinvoice(): + "not implemented on canonical lndhub" + pass