From ecfcc167f0b0703baafd9ab39cbd10eba61063c9 Mon Sep 17 00:00:00 2001 From: benarc Date: Sun, 21 Feb 2021 17:53:43 +0000 Subject: [PATCH] Splitting into x2 extensions Splitting extension into 2, SatsPay Server, a BTCPay Server type extension, and WatchOnly --- lnbits/extensions/satspay/README.md | 4 + lnbits/extensions/satspay/__init__.py | 11 + lnbits/extensions/satspay/config.json | 8 + lnbits/extensions/satspay/crud.py | 80 ++ lnbits/extensions/satspay/migrations.py | 40 + lnbits/extensions/satspay/models.py | 17 + .../templates/watchonly/_api_docs.html | 141 +++ .../satspay/templates/watchonly/display.html | 54 ++ .../satspay/templates/watchonly/index.html | 847 ++++++++++++++++++ lnbits/extensions/satspay/views.py | 21 + lnbits/extensions/satspay/views_api.py | 157 ++++ 11 files changed, 1380 insertions(+) create mode 100644 lnbits/extensions/satspay/README.md create mode 100644 lnbits/extensions/satspay/__init__.py create mode 100644 lnbits/extensions/satspay/config.json create mode 100644 lnbits/extensions/satspay/crud.py create mode 100644 lnbits/extensions/satspay/migrations.py create mode 100644 lnbits/extensions/satspay/models.py create mode 100644 lnbits/extensions/satspay/templates/watchonly/_api_docs.html create mode 100644 lnbits/extensions/satspay/templates/watchonly/display.html create mode 100644 lnbits/extensions/satspay/templates/watchonly/index.html create mode 100644 lnbits/extensions/satspay/views.py create mode 100644 lnbits/extensions/satspay/views_api.py diff --git a/lnbits/extensions/satspay/README.md b/lnbits/extensions/satspay/README.md new file mode 100644 index 00000000..5f7511ca --- /dev/null +++ b/lnbits/extensions/satspay/README.md @@ -0,0 +1,4 @@ +# SatsPay Server + +Create onchain and LN charges. Includes webhooks! + diff --git a/lnbits/extensions/satspay/__init__.py b/lnbits/extensions/satspay/__init__.py new file mode 100644 index 00000000..7023f7a9 --- /dev/null +++ b/lnbits/extensions/satspay/__init__.py @@ -0,0 +1,11 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_satspay") + + +satspay_ext: Blueprint = Blueprint("satspay", __name__, static_folder="static", template_folder="templates") + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/satspay/config.json b/lnbits/extensions/satspay/config.json new file mode 100644 index 00000000..7e9e1a59 --- /dev/null +++ b/lnbits/extensions/satspay/config.json @@ -0,0 +1,8 @@ +{ + "name": "SatsPay Server", + "short_description": "Create onchain and LN charges", + "icon": "visibility", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py new file mode 100644 index 00000000..18934eb6 --- /dev/null +++ b/lnbits/extensions/satspay/crud.py @@ -0,0 +1,80 @@ +from typing import List, Optional, Union + +#from lnbits.db import open_ext_db +from . import db +from .models import Charges + +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 +import httpx + + +###############CHARGES########################## + + +async def create_charge(walletid: str, user: str, title: Optional[str] = None, time: Optional[int] = None, amount: Optional[int] = None) -> Charges: + wallet = await get_watch_wallet(walletid) + address = await get_derive_address(walletid, wallet[4] + 1) + + charge_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO charges ( + id, + user, + title, + wallet, + address, + time_to_pay, + amount, + balance + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (charge_id, user, title, walletid, address, time, amount, 0), + ) + return await get_charge(charge_id) + + +async def get_charge(charge_id: str) -> Charges: + row = await db.fetchone("SELECT * FROM charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None + + +async def get_charges(user: str) -> List[Charges]: + rows = await db.fetchall("SELECT * FROM charges WHERE user = ?", (user,)) + for row in rows: + await check_address_balance(row.address) + rows = await db.fetchall("SELECT * FROM charges WHERE user = ?", (user,)) + return [charges.from_row(row) for row in rows] + + +async def delete_charge(charge_id: str) -> None: + await db.execute("DELETE FROM charges WHERE id = ?", (charge_id,)) + +async def check_address_balance(address: str) -> List[Charges]: + address_data = await get_address(address) + mempool = await get_mempool(address_data.user) + + try: + async with httpx.AsyncClient() as client: + r = await client.get(mempool.endpoint + "/api/address/" + address) + except Exception: + pass + + amount_paid = r.json()['chain_stats']['funded_txo_sum'] - r.json()['chain_stats']['spent_txo_sum'] + print(amount_paid) diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py new file mode 100644 index 00000000..e5283d7a --- /dev/null +++ b/lnbits/extensions/satspay/migrations.py @@ -0,0 +1,40 @@ +async def m001_initial(db): + """ + Initial wallet table. + """ + await 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, + balance INTEGER NOT NULL + ); + """ + ) + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS charges ( + id TEXT NOT NULL PRIMARY KEY, + user TEXT, + title TEXT, + wallet TEXT NOT NULL, + address TEXT NOT NULL, + time_to_pay INTEGER, + amount INTEGER, + balance INTEGER DEFAULT 0, + time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) + ); + """ + ) + await 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/satspay/models.py b/lnbits/extensions/satspay/models.py new file mode 100644 index 00000000..ea1afe42 --- /dev/null +++ b/lnbits/extensions/satspay/models.py @@ -0,0 +1,17 @@ +from sqlite3 import Row +from typing import NamedTuple + +class Charges(NamedTuple): + id: str + user: str + wallet: str + title: str + address: str + time_to_pay: str + amount: int + balance: int + time: int + + @classmethod + def from_row(cls, row: Row) -> "Payments": + return cls(**dict(row)) \ No newline at end of file diff --git a/lnbits/extensions/satspay/templates/watchonly/_api_docs.html b/lnbits/extensions/satspay/templates/watchonly/_api_docs.html new file mode 100644 index 00000000..9ef27cb2 --- /dev/null +++ b/lnbits/extensions/satspay/templates/watchonly/_api_docs.html @@ -0,0 +1,141 @@ + + +

SatsPay: Create Onchain/LN charges. Includes webhooks!
+ + 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/satspay/templates/watchonly/display.html b/lnbits/extensions/satspay/templates/watchonly/display.html new file mode 100644 index 00000000..11af36ac --- /dev/null +++ b/lnbits/extensions/satspay/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/satspay/templates/watchonly/index.html b/lnbits/extensions/satspay/templates/watchonly/index.html new file mode 100644 index 00000000..b75977f1 --- /dev/null +++ b/lnbits/extensions/satspay/templates/watchonly/index.html @@ -0,0 +1,847 @@ +{% 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 %} + + + + + +
+
+ + + + +
+ + + +
+ + +
+ LNbits satspay Extension +
+
+ + + + {% include "satspay/_api_docs.html" %} + + +
+
+ + + + + + + + +
+ Update Watch-only Wallet + Create Watch-only Wallet + Cancel +
+
+
+
+ + + + + + + + + + + + +
+ Update Paylink + Create Paylink + Cancel +
+
+
+
+ + + + + {% raw %} +
Addresses
+
+

Current: + {{ currentaddress }} + + +

+ + + +

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

+ +
+ Get fresh address + Close +
+
+
+{% endraw %} + +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + +{% endblock %} diff --git a/lnbits/extensions/satspay/views.py b/lnbits/extensions/satspay/views.py new file mode 100644 index 00000000..8891ce94 --- /dev/null +++ b/lnbits/extensions/satspay/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 . import satspay_ext +from .crud import get_charge + + +@satspay_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("satspay/index.html", user=g.user) + + +@satspay_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("satspay/display.html", link=link) \ No newline at end of file diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py new file mode 100644 index 00000000..2617cc56 --- /dev/null +++ b/lnbits/extensions/satspay/views_api.py @@ -0,0 +1,157 @@ +import hashlib +from quart import g, jsonify, url_for +from http import HTTPStatus +import httpx + + +from lnbits.core.crud import get_user +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from lnbits.extensions.satspay import satspay_ext +from .crud import ( + create_charge, + get_charge, + get_charges, + delete_charge, +) + +###################WALLETS############################# + +@satspay_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 "" + +@satspay_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}), HTTPStatus.OK + + +@satspay_ext.route("/api/v1/wallet", methods=["POST"]) +@satspay_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 = await create_watch_wallet(user=g.wallet.user, masterpub=g.data["masterpub"], title=g.data["title"]) + mempool = await get_mempool(g.wallet.user) + if not mempool: + create_mempool(user=g.wallet.user) + return jsonify(wallet._asdict()), HTTPStatus.CREATED + else: + wallet = await update_watch_wallet(wallet_id=wallet_id, **g.data) + return jsonify(wallet._asdict()), HTTPStatus.OK + + +@satspay_ext.route("/api/v1/wallet/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +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 + + +#############################CHARGES########################## + +@satspay_ext.route("/api/v1/charges", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_charges_retrieve(): + + charges = await get_charges(g.wallet.user) + if not charges: + return ( + jsonify(""), + HTTPStatus.OK + ) + else: + return jsonify([charge._asdict() for charge in charges]), HTTPStatus.OK + + +@satspay_ext.route("/api/v1/charge/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_charge_retrieve(charge_id): + charge = get_charge(charge_id) + + if not charge: + return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND + + return jsonify({charge}), HTTPStatus.OK + + +@satspay_ext.route("/api/v1/charge", methods=["POST"]) +@satspay_ext.route("/api/v1/charge/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "walletid": {"type": "string", "empty": False, "required": True}, + "title": {"type": "string", "empty": False, "required": True}, + "time": {"type": "integer", "min": 1, "required": True}, + "amount": {"type": "integer", "min": 1, "required": True}, + } +) +async def api_charge_create_or_update(charge_id=None): + + if not charge_id: + charge = await create_charge(user = g.wallet.user, **g.data) + return jsonify(charge), HTTPStatus.CREATED + + else: + charge = await update_charge(user = g.wallet.user, **g.data) + return jsonify(charge), HTTPStatus.OK + + +@satspay_ext.route("/api/v1/charge/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_charge_delete(charge_id): + charge = await get_watch_wallet(charge_id) + + if not charge: + return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND + + await delete_watch_wallet(charge_id) + + return "", HTTPStatus.NO_CONTENT + +#############################MEMPOOL########################## + +@satspay_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 = await update_mempool(user=g.wallet.user, **g.data) + return jsonify(mempool._asdict()), HTTPStatus.OK + +@satspay_ext.route("/api/v1/mempool", methods=["GET"]) +@api_check_wallet_key("invoice") +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 \ No newline at end of file