From ec89244d7faec303c696bb16501badba6eb96cbd Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 14 Oct 2021 11:45:30 +0100 Subject: [PATCH] whatchonly ext added --- lnbits/extensions/watchonly/README.md | 19 + lnbits/extensions/watchonly/__init__.py | 13 + lnbits/extensions/watchonly/config.json | 8 + lnbits/extensions/watchonly/crud.py | 212 ++++++ lnbits/extensions/watchonly/migrations.py | 36 + lnbits/extensions/watchonly/models.py | 35 + .../templates/watchonly/_api_docs.html | 244 +++++++ .../watchonly/templates/watchonly/index.html | 649 ++++++++++++++++++ lnbits/extensions/watchonly/views.py | 22 + lnbits/extensions/watchonly/views_api.py | 138 ++++ 10 files changed, 1376 insertions(+) create mode 100644 lnbits/extensions/watchonly/README.md create mode 100644 lnbits/extensions/watchonly/__init__.py create mode 100644 lnbits/extensions/watchonly/config.json create mode 100644 lnbits/extensions/watchonly/crud.py create mode 100644 lnbits/extensions/watchonly/migrations.py create mode 100644 lnbits/extensions/watchonly/models.py create mode 100644 lnbits/extensions/watchonly/templates/watchonly/_api_docs.html create mode 100644 lnbits/extensions/watchonly/templates/watchonly/index.html create mode 100644 lnbits/extensions/watchonly/views.py create mode 100644 lnbits/extensions/watchonly/views_api.py diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md new file mode 100644 index 000000000..d93f7162d --- /dev/null +++ b/lnbits/extensions/watchonly/README.md @@ -0,0 +1,19 @@ +# Watch Only wallet + +## Monitor an onchain wallet and generate addresses for onchain payments + +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. + +1. Start by clicking "NEW WALLET"\ + ![new wallet](https://i.imgur.com/vgbAB7c.png) +2. Fill the requested fields: + - give the wallet a name + - paste an Extended Public Key (xpub, ypub, zpub) + - click "CREATE WATCH-ONLY WALLET"\ + ![fill wallet form](https://i.imgur.com/UVoG7LD.png) +3. You can then access your onchain addresses\ + ![get address](https://i.imgur.com/zkxTQ6l.png) +4. You can then generate bitcoin onchain adresses from LNbits\ + ![onchain address](https://i.imgur.com/4KVSSJn.png) + +You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension diff --git a/lnbits/extensions/watchonly/__init__.py b/lnbits/extensions/watchonly/__init__.py new file mode 100644 index 000000000..b8df31978 --- /dev/null +++ b/lnbits/extensions/watchonly/__init__.py @@ -0,0 +1,13 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_watchonly") + + +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 000000000..48c19ef07 --- /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 000000000..bd301eb44 --- /dev/null +++ b/lnbits/extensions/watchonly/crud.py @@ -0,0 +1,212 @@ +from typing import List, Optional + +from . import db +from .models import Wallets, Addresses, Mempool + +from lnbits.helpers import urlsafe_short_hash + +from embit.descriptor import Descriptor, Key # type: ignore +from embit.descriptor.arguments import AllowedDerivation # type: ignore +from embit.networks import NETWORKS # type: ignore + + +##########################WALLETS#################### + + +def detect_network(k): + version = k.key.version + for network_name in NETWORKS: + net = NETWORKS[network_name] + # not found in this network + if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]: + return net + + +def parse_key(masterpub: str): + """Parses masterpub or descriptor and returns a tuple: (Descriptor, network) + To create addresses use descriptor.derive(num).address(network=network) + """ + network = None + # probably a single key + if "(" not in masterpub: + k = Key.from_string(masterpub) + if not k.is_extended: + raise ValueError("The key is not a master public key") + if k.is_private: + raise ValueError("Private keys are not allowed") + # check depth + if k.key.depth != 3: + raise ValueError( + "Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors." + ) + # if allowed derivation is not provided use default /{0,1}/* + if k.allowed_derivation is None: + k.allowed_derivation = AllowedDerivation.default() + # get version bytes + version = k.key.version + for network_name in NETWORKS: + net = NETWORKS[network_name] + # not found in this network + if version in [net["xpub"], net["ypub"], net["zpub"]]: + network = net + if version == net["xpub"]: + desc = Descriptor.from_string("pkh(%s)" % str(k)) + elif version == net["ypub"]: + desc = Descriptor.from_string("sh(wpkh(%s))" % str(k)) + elif version == net["zpub"]: + desc = Descriptor.from_string("wpkh(%s)" % str(k)) + break + # we didn't find correct version + if network is None: + raise ValueError("Unknown master public key version") + else: + desc = Descriptor.from_string(masterpub) + if not desc.is_wildcard: + raise ValueError("Descriptor should have wildcards") + for k in desc.keys: + if k.is_extended: + net = detect_network(k) + if net is None: + raise ValueError(f"Unknown version: {k}") + if network is not None and network != net: + raise ValueError("Keys from different networks") + network = net + return desc, network + + +async def create_watch_wallet(*, user: str, masterpub: str, title: str) -> Wallets: + # check the masterpub is fine, it will raise an exception if not + parse_key(masterpub) + wallet_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO watchonly.wallets ( + id, + "user", + masterpub, + title, + address_no, + balance + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + # address_no is -1 so fresh address on empty wallet can get address with index 0 + (wallet_id, user, masterpub, title, -1, 0), + ) + + return await get_watch_wallet(wallet_id) + + +async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]: + row = await db.fetchone( + "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) + ) + return Wallets.from_row(row) if row else None + + +async def get_watch_wallets(user: str) -> List[Wallets]: + rows = await db.fetchall( + """SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,) + ) + return [Wallets(**row) for row in rows] + + +async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"UPDATE watchonly.wallets SET {q} WHERE id = ?", (*kwargs.values(), wallet_id) + ) + row = await db.fetchone( + "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) + ) + return Wallets.from_row(row) if row else None + + +async def delete_watch_wallet(wallet_id: str) -> None: + await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,)) + + ########################ADDRESSES####################### + + +async def get_derive_address(wallet_id: str, num: int): + wallet = await get_watch_wallet(wallet_id) + key = wallet[2] + desc, network = parse_key(key) + return desc.derive(num).address(network=network) + + +async def get_fresh_address(wallet_id: str) -> Optional[Addresses]: + wallet = await get_watch_wallet(wallet_id) + if not wallet: + return None + + address = await get_derive_address(wallet_id, wallet[4] + 1) + + await update_watch_wallet(wallet_id=wallet_id, address_no=wallet[4] + 1) + masterpub_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO watchonly.addresses ( + id, + address, + wallet, + amount + ) + VALUES (?, ?, ?, ?) + """, + (masterpub_id, address, wallet_id, 0), + ) + + return await get_address(address) + + +async def get_address(address: str) -> Optional[Addresses]: + row = await db.fetchone( + "SELECT * FROM watchonly.addresses WHERE address = ?", (address,) + ) + return Addresses.from_row(row) if row else None + + +async def get_addresses(wallet_id: str) -> List[Addresses]: + rows = await db.fetchall( + "SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,) + ) + return [Addresses(**row) for row in rows] + + +######################MEMPOOL####################### + + +async def create_mempool(user: str) -> Optional[Mempool]: + await db.execute( + """ + INSERT INTO watchonly.mempool ("user",endpoint) + VALUES (?, ?) + """, + (user, "https://mempool.space"), + ) + row = await db.fetchone( + """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) + ) + return Mempool.from_row(row) if row else None + + +async def update_mempool(user: str, **kwargs) -> Optional[Mempool]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"""UPDATE watchonly.mempool SET {q} WHERE "user" = ?""", + (*kwargs.values(), user), + ) + row = await db.fetchone( + """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) + ) + return Mempool.from_row(row) if row else None + + +async def get_mempool(user: str) -> Mempool: + row = await db.fetchone( + """SELECT * FROM watchonly.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 000000000..05c229b53 --- /dev/null +++ b/lnbits/extensions/watchonly/migrations.py @@ -0,0 +1,36 @@ +async def m001_initial(db): + """ + Initial wallet table. + """ + await db.execute( + """ + CREATE TABLE watchonly.wallets ( + id TEXT NOT NULL PRIMARY KEY, + "user" TEXT, + masterpub TEXT NOT NULL, + title TEXT NOT NULL, + address_no INTEGER NOT NULL DEFAULT 0, + balance INTEGER NOT NULL + ); + """ + ) + + await db.execute( + """ + CREATE TABLE watchonly.addresses ( + id TEXT NOT NULL PRIMARY KEY, + address TEXT NOT NULL, + wallet TEXT NOT NULL, + amount INTEGER NOT NULL + ); + """ + ) + + await db.execute( + """ + CREATE TABLE watchonly.mempool ( + "user" TEXT NOT NULL, + endpoint TEXT NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py new file mode 100644 index 000000000..b9faa6019 --- /dev/null +++ b/lnbits/extensions/watchonly/models.py @@ -0,0 +1,35 @@ +from sqlite3 import Row +from typing import NamedTuple + + +class Wallets(NamedTuple): + id: str + user: str + masterpub: str + title: str + address_no: int + balance: int + + @classmethod + def from_row(cls, row: Row) -> "Wallets": + return cls(**dict(row)) + + +class Mempool(NamedTuple): + user: str + endpoint: str + + @classmethod + def from_row(cls, row: Row) -> "Mempool": + return cls(**dict(row)) + + +class Addresses(NamedTuple): + id: str + address: str + wallet: str + amount: int + + @classmethod + def from_row(cls, row: Row) -> "Addresses": + return cls(**dict(row)) 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 000000000..97fdb8a90 --- /dev/null +++ b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html @@ -0,0 +1,244 @@ + + +

+ Watch Only extension uses mempool.space
+ For use with "account Extended Public Key" + https://iancoleman.io/bip39/ + +
Created by, + Ben Arc (using, + Embit
) +

+
+ + + + + + GET /watchonly/api/v1/wallet +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<wallets_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/wallet -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /watchonly/api/v1/wallet/<wallet_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 201 CREATED (application/json) +
+ [<wallet_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/wallet/<wallet_id> + -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /watchonly/api/v1/wallet +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 201 CREATED (application/json) +
+ [<wallet_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/wallet -d '{"title": + <string>, "masterpub": <string>}' -H "Content-type: + application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /watchonly/api/v1/wallet/<wallet_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/wallet/<wallet_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + + GET + /watchonly/api/v1/addresses/<wallet_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<address_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/addresses/<wallet_id> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + + GET + /watchonly/api/v1/address/<wallet_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<address_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/address/<wallet_id> + -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + + GET /watchonly/api/v1/mempool +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<mempool_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/mempool -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + + POST + /watchonly/api/v1/mempool +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 201 CREATED (application/json) +
+ [<mempool_object>, ...] +
Curl example
+ curl -X PUT {{ request.url_root }}api/v1/mempool -d '{"endpoint": + <string>}' -H "Content-type: application/json" -H "X-Api-Key: + {{ g.user.wallets[0].adminkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html new file mode 100644 index 000000000..5230e298b --- /dev/null +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -0,0 +1,649 @@ +{% 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
+
+
+ + + +
+
+ + + + +
+
+ + +
+
{{satBtc(utxos.total)}}
+ + {{utxos.sats ? ' sats' : ' BTC'}} +
+
+ + + +
+
+
Transactions
+
+
+ + + +
+
+ + + + +
+
+
+ + {% endraw %} + +
+ + +
+ {{SITE_TITLE}} Watch Only Extension +
+
+ + + {% include "watchonly/_api_docs.html" %} + +
+
+ + + + + + + + +
+ Create Watch-only Wallet + Cancel +
+
+
+
+ + + + {% raw %} +
Addresses
+
+

+ Current: + {{ currentaddress }} + +

+ + + +

+ + + + {{ data.address }} + + + + +

+ +
+ Get fresh address + Close +
+
+
+ {% endraw %} +
+{% 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 000000000..e82469680 --- /dev/null +++ b/lnbits/extensions/watchonly/views.py @@ -0,0 +1,22 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import watchonly_ext + + +@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(charge_id): + link = get_payment(charge_id) or abort( + HTTPStatus.NOT_FOUND, "Charge link does not exist." + ) + + return await render_template("watchonly/display.html", link=link) diff --git a/lnbits/extensions/watchonly/views_api.py b/lnbits/extensions/watchonly/views_api.py new file mode 100644 index 000000000..01ae25277 --- /dev/null +++ b/lnbits/extensions/watchonly/views_api.py @@ -0,0 +1,138 @@ +import hashlib +from quart import g, jsonify, url_for, request +from http import HTTPStatus +import httpx +import json + +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, + get_fresh_address, + get_addresses, + create_mempool, + update_mempool, + get_mempool, +) + +###################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 await get_watch_wallets(g.wallet.user)] + ), + HTTPStatus.OK, + ) + except: + return "" + + +@watchonly_ext.route("/api/v1/wallet/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_wallet_retrieve(wallet_id): + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND + + return jsonify(wallet._asdict()), HTTPStatus.OK + + +@watchonly_ext.route("/api/v1/wallet", methods=["POST"]) +@api_check_wallet_key("admin") +@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): + try: + wallet = await create_watch_wallet( + user=g.wallet.user, masterpub=g.data["masterpub"], title=g.data["title"] + ) + except Exception as e: + return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST + mempool = await get_mempool(g.wallet.user) + if not mempool: + create_mempool(user=g.wallet.user) + return jsonify(wallet._asdict()), HTTPStatus.CREATED + + +@watchonly_ext.route("/api/v1/wallet/", methods=["DELETE"]) +@api_check_wallet_key("admin") +async def api_wallet_delete(wallet_id): + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND + + await 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): + await get_fresh_address(wallet_id) + + addresses = await get_addresses(wallet_id) + + return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK + + +@watchonly_ext.route("/api/v1/addresses/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_get_addresses(wallet_id): + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND + + addresses = await get_addresses(wallet_id) + + if not addresses: + await get_fresh_address(wallet_id) + addresses = await get_addresses(wallet_id) + + return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK + + +#############################MEMPOOL########################## + + +@watchonly_ext.route("/api/v1/mempool", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "endpoint": {"type": "string", "empty": False, "required": True}, + } +) +async def api_update_mempool(): + mempool = await 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("admin") +async def api_get_mempool(): + mempool = await get_mempool(g.wallet.user) + if not mempool: + mempool = await create_mempool(user=g.wallet.user) + return jsonify(mempool._asdict()), HTTPStatus.OK