diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md new file mode 100644 index 00000000..515be9aa --- /dev/null +++ b/lnbits/extensions/watchonly/README.md @@ -0,0 +1,4 @@ +# Watch Only wallet + +Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API. + diff --git a/lnbits/extensions/watchonly/__init__.py b/lnbits/extensions/watchonly/__init__.py new file mode 100644 index 00000000..34c849a8 --- /dev/null +++ b/lnbits/extensions/watchonly/__init__.py @@ -0,0 +1,8 @@ +from quart import Blueprint + + +watchonly_ext: Blueprint = Blueprint("watchonly", __name__, static_folder="static", template_folder="templates") + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/watchonly/config.json b/lnbits/extensions/watchonly/config.json new file mode 100644 index 00000000..48c19ef0 --- /dev/null +++ b/lnbits/extensions/watchonly/config.json @@ -0,0 +1,8 @@ +{ + "name": "Watch Only", + "short_description": "Onchain watch only wallets", + "icon": "visibility", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/watchonly/crud.py b/lnbits/extensions/watchonly/crud.py new file mode 100644 index 00000000..1a1fec21 --- /dev/null +++ b/lnbits/extensions/watchonly/crud.py @@ -0,0 +1,186 @@ +from typing import List, Optional, Union + +from lnbits.db import open_ext_db + +from .models import Wallets, Payments, Addresses, Mempool +from lnbits.helpers import urlsafe_short_hash + +from embit import bip32 +from embit import ec +from embit.networks import NETWORKS +from embit import base58 +from embit.util import hashlib +import io +from embit.util import secp256k1 +from embit import hashes +from binascii import hexlify +from quart import jsonify +from embit import script +from embit import ec +from embit.networks import NETWORKS +from binascii import unhexlify, hexlify, a2b_base64, b2a_base64 + +########################ADDRESSES####################### + +def get_derive_address(wallet_id: str, num: int): + + wallet = get_watch_wallet(wallet_id) + k = bip32.HDKey.from_base58(str(wallet[2])) + child = k.derive([0, num]) + address = script.p2wpkh(child).address() + + return address + +def get_fresh_address(wallet_id: str) -> Addresses: + wallet = get_watch_wallet(wallet_id) + + address = get_derive_address(wallet_id, wallet[4] + 1) + + update_watch_wallet(wallet_id = wallet_id, address_no = wallet[4] + 1) + with open_ext_db("watchonly") as db: + db.execute( + """ + INSERT INTO addresses ( + address, + wallet, + amount + ) + VALUES (?, ?, ?) + """, + (address, wallet_id, 0), + ) + + return get_address(address) + + +def get_address(address: str) -> Addresses: + with open_ext_db("watchonly") as db: + row = db.fetchone("SELECT * FROM addresses WHERE address = ?", (address,)) + return Addresses.from_row(row) if row else None + + +def get_addresses(wallet_id: str) -> List[Addresses]: + with open_ext_db("watchonly") as db: + rows = db.fetchall("SELECT * FROM addresses WHERE wallet = ?", (wallet_id,)) + return [Addresses(**row) for row in rows] + + +##########################WALLETS#################### + +def create_watch_wallet(*, user: str, masterpub: str, title: str) -> Wallets: + wallet_id = urlsafe_short_hash() + with open_ext_db("watchonly") as db: + db.execute( + """ + INSERT INTO wallets ( + id, + user, + masterpub, + title, + address_no, + amount + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (wallet_id, user, masterpub, title, 0, 0), + ) + # weallet_id = db.cursor.lastrowid + address = get_fresh_address(wallet_id) + return get_watch_wallet(wallet_id) + + +def get_watch_wallet(wallet_id: str) -> Wallets: + with open_ext_db("watchonly") as db: + row = db.fetchone("SELECT * FROM wallets WHERE id = ?", (wallet_id,)) + return Wallets.from_row(row) if row else None + +def get_watch_wallets(user: str) -> List[Wallets]: + with open_ext_db("watchonly") as db: + rows = db.fetchall("SELECT * FROM wallets WHERE user = ?", (user,)) + return [Wallets(**row) for row in rows] + +def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + with open_ext_db("watchonly") as db: + db.execute(f"UPDATE wallets SET {q} WHERE id = ?", (*kwargs.values(), wallet_id)) + row = db.fetchone("SELECT * FROM wallets WHERE id = ?", (wallet_id,)) + return Wallets.from_row(row) if row else None + + +def delete_watch_wallet(wallet_id: str) -> None: + with open_ext_db("watchonly") as db: + db.execute("DELETE FROM wallets WHERE id = ?", (wallet_id,)) + + +###############PAYMENTS########################## + +def create_payment(*, user: str, ex_key: str, description: str, amount: int) -> Payments: + + address = get_fresh_address(ex_key) + payment_id = urlsafe_short_hash() + with open_ext_db("watchonly") as db: + db.execute( + """ + INSERT INTO payments ( + payment_id, + user, + ex_key, + address, + amount + ) + VALUES (?, ?, ?, ?, ?) + """, + (payment_id, user, ex_key, address, amount), + ) + payment_id = db.cursor.lastrowid + return get_payment(payment_id) + + +def get_payment(payment_id: str) -> Payments: + with open_ext_db("watchonly") as db: + row = db.fetchone("SELECT * FROM payments WHERE id = ?", (payment_id,)) + return Payments.from_row(row) if row else None + + +def get_payments(user: str) -> List[Payments]: + with open_ext_db("watchonly") as db: + rows = db.fetchall("SELECT * FROM payments WHERE user IN ?", (user,)) + return [Payments.from_row(row) for row in rows] + + +def delete_payment(payment_id: str) -> None: + with open_ext_db("watchonly") as db: + db.execute("DELETE FROM payments WHERE id = ?", (payment_id,)) + + +######################MEMPOOL####################### + +def create_mempool(user: str) -> Mempool: + with open_ext_db("watchonly") as db: + db.execute( + """ + INSERT INTO mempool ( + user, + endpoint + ) + VALUES (?, ?) + """, + (user, 'https://mempool.space'), + ) + row = db.fetchone("SELECT * FROM mempool WHERE user = ?", (user,)) + return Mempool.from_row(row) if row else None + +def update_mempool(user: str, **kwargs) -> Optional[Mempool]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + with open_ext_db("watchonly") as db: + db.execute(f"UPDATE mempool SET {q} WHERE user = ?", (*kwargs.values(), user)) + row = db.fetchone("SELECT * FROM mempool WHERE user = ?", (user,)) + return Mempool.from_row(row) if row else None + + +def get_mempool(user: str) -> Mempool: + with open_ext_db("watchonly") as db: + row = db.fetchone("SELECT * FROM mempool WHERE user = ?", (user,)) + return Mempool.from_row(row) if row else None diff --git a/lnbits/extensions/watchonly/migrations.py b/lnbits/extensions/watchonly/migrations.py new file mode 100644 index 00000000..fee05cdd --- /dev/null +++ b/lnbits/extensions/watchonly/migrations.py @@ -0,0 +1,48 @@ +def m001_initial(db): + """ + Initial wallet table. + """ + db.execute( + """ + CREATE TABLE IF NOT EXISTS wallets ( + id TEXT NOT NULL PRIMARY KEY, + user TEXT, + masterpub TEXT NOT NULL, + title TEXT NOT NULL, + address_no INTEGER NOT NULL DEFAULT 0, + amount INTEGER NOT NULL + ); + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS addresses ( + address TEXT NOT NULL PRIMARY KEY, + wallet TEXT NOT NULL, + amount INTEGER NOT NULL + ); + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS payments ( + id TEXT NOT NULL PRIMARY KEY, + user TEXT, + masterpub TEXT NOT NULL, + address TEXT NOT NULL, + time_to_pay INTEGER NOT NULL, + amount INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) + ); + """ + ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS mempool ( + user TEXT NOT NULL, + endpoint TEXT NOT NULL + ); + """ + ) \ No newline at end of file diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py new file mode 100644 index 00000000..fc3a726e --- /dev/null +++ b/lnbits/extensions/watchonly/models.py @@ -0,0 +1,44 @@ +from sqlite3 import Row +from typing import NamedTuple + +class Wallets(NamedTuple): + id: str + user: str + masterpub: str + title: str + address_no: int + amount: int + + @classmethod + def from_row(cls, row: Row) -> "Wallets": + return cls(**dict(row)) + +class Payments(NamedTuple): + id: str + user: str + ex_key: str + address: str + time_to_pay: str + amount: int + time: int + + @classmethod + def from_row(cls, row: Row) -> "Payments": + return cls(**dict(row)) + +class Addresses(NamedTuple): + address: str + wallet: str + amount: int + + @classmethod + def from_row(cls, row: Row) -> "Addresses": + return cls(**dict(row)) + +class Mempool(NamedTuple): + user: str + endpoint: str + + @classmethod + def from_row(cls, row: Row) -> "Mempool": + return cls(**dict(row)) \ No newline at end of file diff --git a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html new file mode 100644 index 00000000..9b83e05a --- /dev/null +++ b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html @@ -0,0 +1,141 @@ + + +

The WatchOnly extension uses https://mempool.block for blockchain data.
+ + Created by, Ben Arc +

+
+ + + + + + + + GET /pay/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<pay_link_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}pay/api/v1/links -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /pay/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X GET {{ request.url_root }}pay/api/v1/links/<pay_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /pay/api/v1/links +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"description": <string> "amount": <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X POST {{ request.url_root }}pay/api/v1/links -d + '{"description": <string>, "amount": <integer>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /pay/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"description": <string>, "amount": <integer>} +
+ Returns 200 OK (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X PUT {{ request.url_root }}pay/api/v1/links/<pay_id> -d + '{"description": <string>, "amount": <integer>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /pay/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root }}pay/api/v1/links/<pay_id> + -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/watchonly/templates/watchonly/display.html b/lnbits/extensions/watchonly/templates/watchonly/display.html new file mode 100644 index 00000000..11af36ac --- /dev/null +++ b/lnbits/extensions/watchonly/templates/watchonly/display.html @@ -0,0 +1,54 @@ +{% extends "public.html" %} {% block page %} +
+
+ + + +
+ Copy LNURL +
+
+
+
+
+ + +
+ LNbits LNURL-pay link +
+

+ Use a LNURL compatible bitcoin wallet to claim the sats. +

+
+ + + + {% include "lnurlp/_lnurl.html" %} + + +
+
+
+{% endblock %} {% block scripts %} + + +{% endblock %} diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html new file mode 100644 index 00000000..3db624d3 --- /dev/null +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -0,0 +1,710 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +{% raw %} + New wallet + +
+ + Point to another Mempool + + {{ this.mempool.endpoint }} + + + +
set + cancel +
+ +
+
+ +
+
+
+ + + +
+
+
Wallets
+
+
+ + + + +
+
+ + + + +
+
+ + + + + +
+
+
Paylinks
+
+
+ + + +{% endraw %} +
+
+ + + {% raw %} + + + {% endraw %} + + + + + +
+
+ + + + +
+ + + +
+ + +
+ LNbits WatchOnly Extension +
+
+ + + + {% include "watchonly/_api_docs.html" %} + + +
+
+ + + + + + + + +
+ Update Watch-only Wallet + Create Watch-only Wallet + Cancel +
+
+
+
+ + + + + + + + + + + + +
+ Update Paylink + Create Paylink + Cancel +
+
+
+
+ + + + + {% raw %} +
Addresses
+
+

Current: + {{ Addresses.data[0].address }} + + +

+ + + +

+

+ Table of addresses and amount will go here... + +

+ {% endraw %} +
+ Get fresh address + Close +
+
+
+ + +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + +{% endblock %} diff --git a/lnbits/extensions/watchonly/views.py b/lnbits/extensions/watchonly/views.py new file mode 100644 index 00000000..4e5416ba --- /dev/null +++ b/lnbits/extensions/watchonly/views.py @@ -0,0 +1,21 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from lnbits.extensions.watchonly import watchonly_ext +from .crud import get_payment + + +@watchonly_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("watchonly/index.html", user=g.user) + + +@watchonly_ext.route("/") +async def display(payment_id): + link = get_payment(payment_id) or abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.") + + return await render_template("watchonly/display.html", link=link) \ No newline at end of file diff --git a/lnbits/extensions/watchonly/views_api.py b/lnbits/extensions/watchonly/views_api.py new file mode 100644 index 00000000..ceddf35f --- /dev/null +++ b/lnbits/extensions/watchonly/views_api.py @@ -0,0 +1,194 @@ +import hashlib +from quart import g, jsonify, request, url_for +from http import HTTPStatus + +from lnbits.core.crud import get_user +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from lnbits.extensions.watchonly import watchonly_ext +from .crud import ( + create_watch_wallet, + get_watch_wallet, + get_watch_wallets, + update_watch_wallet, + delete_watch_wallet, + create_payment, + get_payment, + get_payments, + delete_payment, + create_mempool, + update_mempool, + get_mempool, + get_addresses, + get_fresh_address, + get_address +) + +###################WALLETS############################# + +@watchonly_ext.route("/api/v1/wallet", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_wallets_retrieve(): + + try: + return ( + jsonify([wallet._asdict() for wallet in get_watch_wallets(g.wallet.user)]), HTTPStatus.OK + ) + except: + return ( + jsonify({"message": "Cant fetch."}), + HTTPStatus.UPGRADE_REQUIRED, + ) + +@watchonly_ext.route("/api/v1/wallet/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_wallet_retrieve(wallet_id): + wallet = get_watch_wallet(wallet_id) + + if not wallet: + return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND + + return jsonify({wallet}), HTTPStatus.OK + + +@watchonly_ext.route("/api/v1/wallet", methods=["POST"]) +@watchonly_ext.route("/api/v1/wallet/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "masterpub": {"type": "string", "empty": False, "required": True}, + "title": {"type": "string", "empty": False, "required": True}, + } +) +async def api_wallet_create_or_update(wallet_id=None): + print("g.data") + if not wallet_id: + wallet = create_watch_wallet(user=g.wallet.user, masterpub=g.data["masterpub"], title=g.data["title"]) + mempool = get_mempool(g.wallet.user) + if not mempool: + create_mempool(user=g.wallet.user) + return jsonify(wallet._asdict()), HTTPStatus.CREATED + + else: + wallet = update_watch_wallet(wallet_id=wallet_id, **g.data) + return jsonify(wallet._asdict()), HTTPStatus.OK + + +@watchonly_ext.route("/api/v1/wallet/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_wallet_delete(wallet_id): + wallet = get_watch_wallet(wallet_id) + + if not wallet: + return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND + + delete_watch_wallet(wallet_id) + + return jsonify({"deleted": "true"}), HTTPStatus.NO_CONTENT + + +#############################ADDRESSES########################## + +@watchonly_ext.route("/api/v1/address/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_fresh_address(wallet_id): + address = get_fresh_address(wallet_id) + + if not address: + return jsonify({"message": "something went wrong"}), HTTPStatus.NOT_FOUND + + return jsonify({address}), HTTPStatus.OK + + +@watchonly_ext.route("/api/v1/addresses/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_get_addresses(wallet_id): + addresses = get_addresses(wallet_id) + print(addresses) + if not addresses: + return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND + + return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK + +#############################PAYEMENTS########################## + +@watchonly_ext.route("/api/v1/payment", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_payments_retrieve(): + + try: + return ( + jsonify(get_payments(g.wallet.user)), + HTTPStatus.OK, + ) + except: + return ( + jsonify({"message": "Cant fetch."}), + HTTPStatus.UPGRADE_REQUIRED, + ) + +@watchonly_ext.route("/api/v1/payment/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_payment_retrieve(payment_id): + payment = get_payment(payment_id) + + if not payment: + return jsonify({"message": "payment does not exist"}), HTTPStatus.NOT_FOUND + + return jsonify({payment}), HTTPStatus.OK + + +@watchonly_ext.route("/api/v1/payment", methods=["POST"]) +@watchonly_ext.route("/api/v1/payment/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "ex_key": {"type": "string", "empty": False, "required": True}, + "pub_key": {"type": "string", "empty": False, "required": True}, + "time_to_pay": {"type": "integer", "min": 1, "required": True}, + "amount": {"type": "integer", "min": 1, "required": True}, + } +) +async def api_payment_create_or_update(payment_id=None): + + if not payment_id: + payment = create_payment(g.wallet.user, g.data.ex_key, g.data.pub_key, g.data.amount) + return jsonify(get_payment(payment)), HTTPStatus.CREATED + + else: + payment = update_payment(payment_id, g.data) + return jsonify({payment}), HTTPStatus.OK + + +@watchonly_ext.route("/api/v1/payment/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_payment_delete(payment_id): + payment = get_watch_wallet(payment_id) + + if not payment: + return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND + + delete_watch_wallet(payment_id) + + return "", HTTPStatus.NO_CONTENT + +#############################MEMPOOL########################## + +@watchonly_ext.route("/api/v1/mempool", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "endpoint": {"type": "string", "empty": False, "required": True}, + } +) +async def api_update_mempool(): + mempool = update_mempool(user=g.wallet.user, **g.data) + return jsonify(mempool._asdict()), HTTPStatus.OK + +@watchonly_ext.route("/api/v1/mempool", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_get_mempool(): + mempool = get_mempool(g.wallet.user) + if not mempool: + mempool = create_mempool(user=g.wallet.user) + return jsonify(mempool._asdict()), HTTPStatus.OK \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 18250051..19d57cb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,3 +46,4 @@ trio==0.16.0 typing-extensions==3.7.4.3 werkzeug==1.0.1 wsproto==1.0.0 +embit==0.1.2 \ No newline at end of file