From 4686f8762603b5744422dd356693828fcbb404cd Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 14 Oct 2021 10:00:33 +0100 Subject: [PATCH 1/6] withdraw route order fixed --- lnbits/extensions/withdraw/lnurl.py | 104 +++++++++++++++------------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py index dadc52e0..3b5f42fc 100644 --- a/lnbits/extensions/withdraw/lnurl.py +++ b/lnbits/extensions/withdraw/lnurl.py @@ -1,11 +1,11 @@ +from fastapi.param_functions import Query +from fastapi import HTTPException import shortuuid # type: ignore from http import HTTPStatus from datetime import datetime from lnbits.core.services import pay_invoice -from fastapi.param_functions import Query from starlette.requests import Request -from starlette.exceptions import HTTPException from . import withdraw_ext from .crud import get_withdraw_link_by_hash, update_withdraw_link @@ -39,60 +39,19 @@ async def api_lnurl_response(request: Request, unique_hash): return link.lnurl_response(request).dict() -# FOR LNURLs WHICH ARE UNIQUE - - -@withdraw_ext.get("/api/v1/lnurl/{unique_hash}/{id_unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_multi_response") -async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash): - link = await get_withdraw_link_by_hash(unique_hash) - - if not link: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="LNURL-withdraw not found." - ) - # return ( - # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, - # HTTPStatus.OK, - # ) - - if link.is_spent: - raise HTTPException( - # WHAT STATUS_CODE TO USE?? - detail="Withdraw is spent." - ) - # return ( - # {"status": "ERROR", "reason": "Withdraw is spent."}, - # HTTPStatus.OK, - # ) - - useslist = link.usescsv.split(",") - found = False - for x in useslist: - tohash = link.id + link.unique_hash + str(x) - if id_unique_hash == shortuuid.uuid(name=tohash): - found = True - if not found: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="LNURL-withdraw not found." - ) - # return ( - # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, - # HTTPStatus.OK, - # ) - - return link.lnurl_response(req=request).dict() - # CALLBACK -@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_callback") -async def api_lnurl_callback(unique_hash, k1: str = Query(...), pr: str = Query(...)): +@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback") +async def api_lnurl_callback(request: Request, + unique_hash: str=Query(...), + k1: str = Query(...), + payment_request: str = Query(..., alias="pr") + ): link = await get_withdraw_link_by_hash(unique_hash) - payment_request = pr now = int(datetime.now().timestamp()) + if not link: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, @@ -163,3 +122,48 @@ async def api_lnurl_callback(unique_hash, k1: str = Query(...), pr: str = Query( return {"status": "ERROR", "reason": str(e)} return {"status": "OK"} + +# FOR LNURLs WHICH ARE UNIQUE + + +@withdraw_ext.get("/api/v1/lnurl/{unique_hash}/{id_unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_multi_response") +async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash): + link = await get_withdraw_link_by_hash(unique_hash) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="LNURL-withdraw not found." + ) + # return ( + # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, + # HTTPStatus.OK, + # ) + + if link.is_spent: + raise HTTPException( + # WHAT STATUS_CODE TO USE?? + detail="Withdraw is spent." + ) + # return ( + # {"status": "ERROR", "reason": "Withdraw is spent."}, + # HTTPStatus.OK, + # ) + + useslist = link.usescsv.split(",") + found = False + for x in useslist: + tohash = link.id + link.unique_hash + str(x) + if id_unique_hash == shortuuid.uuid(name=tohash): + found = True + if not found: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="LNURL-withdraw not found." + ) + # return ( + # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, + # HTTPStatus.OK, + # ) + + return link.lnurl_response(request).dict() From 43f36afeb0c1660aae7c03facc61b8f5baacd20b Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 14 Oct 2021 10:02:02 +0100 Subject: [PATCH 2/6] satspay ext added --- lnbits/extensions/satspay/README.md | 27 + lnbits/extensions/satspay/__init__.py | 13 + lnbits/extensions/satspay/config.json | 8 + lnbits/extensions/satspay/crud.py | 130 ++++ lnbits/extensions/satspay/migrations.py | 28 + lnbits/extensions/satspay/models.py | 39 ++ .../satspay/templates/satspay/_api_docs.html | 171 ++++++ .../satspay/templates/satspay/display.html | 318 ++++++++++ .../satspay/templates/satspay/index.html | 555 ++++++++++++++++++ lnbits/extensions/satspay/views.py | 22 + lnbits/extensions/satspay/views_api.py | 157 +++++ 11 files changed, 1468 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/satspay/_api_docs.html create mode 100644 lnbits/extensions/satspay/templates/satspay/display.html create mode 100644 lnbits/extensions/satspay/templates/satspay/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..d52547ae --- /dev/null +++ b/lnbits/extensions/satspay/README.md @@ -0,0 +1,27 @@ +# SatsPay Server + +## Create onchain and LN charges. Includes webhooks! + +Easilly create invoices that support Lightning Network and on-chain BTC payment. + +1. Create a "NEW CHARGE"\ + ![new charge](https://i.imgur.com/fUl6p74.png) +2. Fill out the invoice fields + - set a descprition for the payment + - the amount in sats + - the time, in minutes, the invoice is valid for, after this period the invoice can't be payed + - set a webhook that will get the transaction details after a successful payment + - set to where the user should redirect after payment + - set the text for the button that will show after payment (not setting this, will display "NONE" in the button) + - select if you want onchain payment, LN payment or both + - depending on what you select you'll have to choose the respective wallets where to receive your payment\ + ![charge form](https://i.imgur.com/F10yRiW.png) +3. The charge will appear on the _Charges_ section\ + ![charges](https://i.imgur.com/zqHpVxc.png) +4. Your costumer/payee will get the payment page + - they can choose to pay on LN\ + ![offchain payment](https://i.imgur.com/4191SMV.png) + - or pay on chain\ + ![onchain payment](https://i.imgur.com/wzLRR5N.png) +5. You can check the state of your charges in LNBits\ + ![invoice state](https://i.imgur.com/JnBd22p.png) diff --git a/lnbits/extensions/satspay/__init__.py b/lnbits/extensions/satspay/__init__.py new file mode 100644 index 00000000..4bdaa2b6 --- /dev/null +++ b/lnbits/extensions/satspay/__init__.py @@ -0,0 +1,13 @@ +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..beb0071c --- /dev/null +++ b/lnbits/extensions/satspay/config.json @@ -0,0 +1,8 @@ +{ + "name": "SatsPay Server", + "short_description": "Create onchain and LN charges", + "icon": "payment", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py new file mode 100644 index 00000000..56cabdbe --- /dev/null +++ b/lnbits/extensions/satspay/crud.py @@ -0,0 +1,130 @@ +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 quart import jsonify +import httpx +from lnbits.core.services import create_invoice, check_invoice_status +from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool + + +###############CHARGES########################## + + +async def create_charge( + user: str, + description: str = None, + onchainwallet: Optional[str] = None, + lnbitswallet: Optional[str] = None, + webhook: Optional[str] = None, + completelink: Optional[str] = None, + completelinktext: Optional[str] = "Back to Merchant", + time: Optional[int] = None, + amount: Optional[int] = None, +) -> Charges: + charge_id = urlsafe_short_hash() + if onchainwallet: + wallet = await get_watch_wallet(onchainwallet) + onchain = await get_fresh_address(onchainwallet) + onchainaddress = onchain.address + else: + onchainaddress = None + if lnbitswallet: + payment_hash, payment_request = await create_invoice( + wallet_id=lnbitswallet, amount=amount, memo=charge_id + ) + else: + payment_hash = None + payment_request = None + await db.execute( + """ + INSERT INTO satspay.charges ( + id, + "user", + description, + onchainwallet, + onchainaddress, + lnbitswallet, + payment_request, + payment_hash, + webhook, + completelink, + completelinktext, + time, + amount, + balance + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + charge_id, + user, + description, + onchainwallet, + onchainaddress, + lnbitswallet, + payment_request, + payment_hash, + webhook, + completelink, + completelinktext, + time, + amount, + 0, + ), + ) + return await get_charge(charge_id) + + +async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE satspay.charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id) + ) + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None + + +async def get_charge(charge_id: str) -> Charges: + row = await db.fetchone("SELECT * FROM satspay.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 satspay.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 satspay.charges WHERE id = ?", (charge_id,)) + + +async def check_address_balance(charge_id: str) -> List[Charges]: + charge = await get_charge(charge_id) + if not charge.paid: + if charge.onchainaddress: + mempool = await get_mempool(charge.user) + try: + async with httpx.AsyncClient() as client: + r = await client.get( + mempool.endpoint + "/api/address/" + charge.onchainaddress + ) + respAmount = r.json()["chain_stats"]["funded_txo_sum"] + if respAmount >= charge.balance: + await update_charge(charge_id=charge_id, balance=respAmount) + except Exception: + pass + if charge.lnbitswallet: + invoice_status = await check_invoice_status( + charge.lnbitswallet, charge.payment_hash + ) + if invoice_status.paid: + return await update_charge(charge_id=charge_id, balance=charge.amount) + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py new file mode 100644 index 00000000..87446c80 --- /dev/null +++ b/lnbits/extensions/satspay/migrations.py @@ -0,0 +1,28 @@ +async def m001_initial(db): + """ + Initial wallet table. + """ + + await db.execute( + """ + CREATE TABLE satspay.charges ( + id TEXT NOT NULL PRIMARY KEY, + "user" TEXT, + description TEXT, + onchainwallet TEXT, + onchainaddress TEXT, + lnbitswallet TEXT, + payment_request TEXT, + payment_hash TEXT, + webhook TEXT, + completelink TEXT, + completelinktext TEXT, + time INTEGER, + amount INTEGER, + balance INTEGER DEFAULT 0, + timestamp TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py new file mode 100644 index 00000000..a7bfa14f --- /dev/null +++ b/lnbits/extensions/satspay/models.py @@ -0,0 +1,39 @@ +from sqlite3 import Row +from typing import NamedTuple +import time + + +class Charges(NamedTuple): + id: str + user: str + description: str + onchainwallet: str + onchainaddress: str + lnbitswallet: str + payment_request: str + payment_hash: str + webhook: str + completelink: str + completelinktext: str + time: int + amount: int + balance: int + timestamp: int + + @classmethod + def from_row(cls, row: Row) -> "Charges": + return cls(**dict(row)) + + @property + def time_elapsed(self): + if (self.timestamp + (self.time * 60)) >= time.time(): + return False + else: + return True + + @property + def paid(self): + if self.balance >= self.amount: + return True + else: + return False diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html new file mode 100644 index 00000000..526af7f3 --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -0,0 +1,171 @@ + + +

+ SatsPayServer, create Onchain/LN charges.
WARNING: If using with the + WatchOnly extension, we highly reccomend using a fresh extended public Key + specifically for SatsPayServer!
+ + Created by, Ben Arc +

+
+ + + + + POST /satspay/api/v1/charge +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/charge -d + '{"onchainwallet": <string, watchonly_wallet_id>, + "description": <string>, "webhook":<string>, "time": + <integer>, "amount": <integer>, "lnbitswallet": + <string, lnbits_wallet_id>}' -H "Content-type: + application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/charge/<charge_id> + -d '{"onchainwallet": <string, watchonly_wallet_id>, + "description": <string>, "webhook":<string>, "time": + <integer>, "amount": <integer>, "lnbitswallet": + <string, lnbits_wallet_id>}' -H "Content-type: + application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}" + +
+
+
+ + + + + GET + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/charge/<charge_id> + -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET /satspay/api/v1/charges +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/charges -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/charge/<charge_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /satspay/api/v1/charges/balance/<charge_id> +
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html new file mode 100644 index 00000000..b3386074 --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/display.html @@ -0,0 +1,318 @@ +{% extends "public.html" %} {% block page %} +
+ +
+
+
{{ charge.description }}
+
+
+
+
Time elapsed
+
+
+
Charge paid
+
+
+ + + + Awaiting payment... + + {% raw %} {{ newTimeLeft }} {% endraw %} + + + +
+
+
+
+ Charge ID: {{ charge.id }} +
+ {% raw %} Total to pay: {{ charge_amount }}sats
+ Amount paid: {{ charge_balance }}

+ Amount due: {{ charge_amount - charge_balance }}sats {% endraw %} +
+
+ +
+
+
+ + + bitcoin onchain payment method not available + + + + pay with lightning + +
+
+ + + bitcoin lightning payment method not available + + + + pay onchain + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+ Pay this
+ lightning-network invoice
+
+ + + + + +
+ Copy invoice +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+ Send {{ charge.amount }}sats
+ to this onchain address
+
+ + + + + +
+ Copy address +
+
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + + + +{% endblock %} diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html new file mode 100644 index 00000000..f3566c7c --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -0,0 +1,555 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + {% raw %} + New charge + + + + + + +
+
+
Charges
+
+ +
+ + + + Export to CSV +
+
+ + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} satspay Extension +
+
+ + + {% include "satspay/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + +
+
+
+
+ +
+
+
+ +
+ +
+ + + +
+ Create Charge + Cancel +
+
+
+
+
+{% 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..2c99a925 --- /dev/null +++ b/lnbits/extensions/satspay/views.py @@ -0,0 +1,22 @@ +from quart import g, abort, render_template, jsonify +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): + charge = await get_charge(charge_id) or abort( + HTTPStatus.NOT_FOUND, "Charge link does not exist." + ) + return await render_template("satspay/display.html", charge=charge) diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py new file mode 100644 index 00000000..9440312a --- /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, + update_charge, + get_charge, + get_charges, + delete_charge, + check_address_balance, +) + +#############################CHARGES########################## + + +@satspay_ext.route("/api/v1/charge", methods=["POST"]) +@satspay_ext.route("/api/v1/charge/", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "onchainwallet": {"type": "string"}, + "lnbitswallet": {"type": "string"}, + "description": {"type": "string", "empty": False, "required": True}, + "webhook": {"type": "string"}, + "completelink": {"type": "string"}, + "completelinktext": {"type": "string"}, + "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._asdict()), HTTPStatus.CREATED + else: + charge = await update_charge(charge_id=charge_id, **g.data) + return jsonify(charge._asdict()), HTTPStatus.OK + + +@satspay_ext.route("/api/v1/charges", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_charges_retrieve(): + try: + return ( + jsonify( + [ + { + **charge._asdict(), + **{"time_elapsed": charge.time_elapsed}, + **{"paid": charge.paid}, + } + for charge in await get_charges(g.wallet.user) + ] + ), + HTTPStatus.OK, + ) + except: + return "" + + +@satspay_ext.route("/api/v1/charge/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_charge_retrieve(charge_id): + charge = await get_charge(charge_id) + + if not charge: + return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND + + return ( + jsonify( + { + **charge._asdict(), + **{"time_elapsed": charge.time_elapsed}, + **{"paid": charge.paid}, + } + ), + 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_charge(charge_id) + + if not charge: + return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND + + await delete_charge(charge_id) + + return "", HTTPStatus.NO_CONTENT + + +#############################BALANCE########################## + + +@satspay_ext.route("/api/v1/charges/balance/", methods=["GET"]) +async def api_charges_balance(charge_id): + + charge = await check_address_balance(charge_id) + + if not charge: + return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND + if charge.paid and charge.webhook: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + charge.webhook, + json={ + "id": charge.id, + "description": charge.description, + "onchainaddress": charge.onchainaddress, + "payment_request": charge.payment_request, + "payment_hash": charge.payment_hash, + "time": charge.time, + "amount": charge.amount, + "balance": charge.balance, + "paid": charge.paid, + "timestamp": charge.timestamp, + "completelink": charge.completelink, + }, + timeout=40, + ) + except AssertionError: + charge.webhook = None + return jsonify(charge._asdict()), HTTPStatus.OK + + +#############################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 From ec89244d7faec303c696bb16501badba6eb96cbd Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 14 Oct 2021 11:45:30 +0100 Subject: [PATCH 3/6] 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 00000000..d93f7162 --- /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 00000000..b8df3197 --- /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 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..bd301eb4 --- /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 00000000..05c229b5 --- /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 00000000..b9faa601 --- /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 00000000..97fdb8a9 --- /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 00000000..5230e298 --- /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 00000000..e8246968 --- /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 00000000..01ae2527 --- /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 From e939666107a36c0a60aab6342bc8a7b2a871aa0d Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 14 Oct 2021 11:45:56 +0100 Subject: [PATCH 4/6] satspay initial converstion --- lnbits/extensions/satspay/__init__.py | 18 ++- lnbits/extensions/satspay/crud.py | 47 +++---- lnbits/extensions/satspay/models.py | 14 +- .../satspay/templates/satspay/_api_docs.html | 8 +- lnbits/extensions/satspay/views.py | 35 +++-- lnbits/extensions/satspay/views_api.py | 123 ++++++++---------- 6 files changed, 130 insertions(+), 115 deletions(-) diff --git a/lnbits/extensions/satspay/__init__.py b/lnbits/extensions/satspay/__init__.py index 4bdaa2b6..7b7f0bde 100644 --- a/lnbits/extensions/satspay/__init__.py +++ b/lnbits/extensions/satspay/__init__.py @@ -1,13 +1,25 @@ -from quart import Blueprint +import asyncio + +from fastapi import APIRouter + from lnbits.db import Database +from lnbits.helpers import template_renderer db = Database("ext_satspay") -satspay_ext: Blueprint = Blueprint( - "satspay", __name__, static_folder="static", template_folder="templates" +satspay_ext: APIRouter = APIRouter( + prefix="/satspay", + tags=["satspay"] ) +def satspay_renderer(): + return template_renderer( + [ + "lnbits/extensions/satspay/templates", + ] + ) + from .views_api import * # noqa from .views import * # noqa diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py index 56cabdbe..fab0406f 100644 --- a/lnbits/extensions/satspay/crud.py +++ b/lnbits/extensions/satspay/crud.py @@ -2,11 +2,10 @@ from typing import List, Optional, Union # from lnbits.db import open_ext_db from . import db -from .models import Charges +from .models import Charges, CreateCharge from lnbits.helpers import urlsafe_short_hash -from quart import jsonify import httpx from lnbits.core.services import create_invoice, check_invoice_status from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool @@ -17,25 +16,27 @@ from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool async def create_charge( user: str, - description: str = None, - onchainwallet: Optional[str] = None, - lnbitswallet: Optional[str] = None, - webhook: Optional[str] = None, - completelink: Optional[str] = None, - completelinktext: Optional[str] = "Back to Merchant", - time: Optional[int] = None, - amount: Optional[int] = None, + data: CreateCharge + # user: str, + # description: str = None, + # onchainwallet: Optional[str] = None, + # lnbitswallet: Optional[str] = None, + # webhook: Optional[str] = None, + # completelink: Optional[str] = None, + # completelinktext: Optional[str] = "Back to Merchant", + # time: Optional[int] = None, + # amount: Optional[int] = None, ) -> Charges: charge_id = urlsafe_short_hash() - if onchainwallet: - wallet = await get_watch_wallet(onchainwallet) - onchain = await get_fresh_address(onchainwallet) + if data.onchainwallet: + wallet = await get_watch_wallet(data.onchainwallet) + onchain = await get_fresh_address(data.onchainwallet) onchainaddress = onchain.address else: onchainaddress = None - if lnbitswallet: + if data.lnbitswallet: payment_hash, payment_request = await create_invoice( - wallet_id=lnbitswallet, amount=amount, memo=charge_id + wallet_id=data.lnbitswallet, amount=data.amount, memo=charge_id ) else: payment_hash = None @@ -63,17 +64,17 @@ async def create_charge( ( charge_id, user, - description, - onchainwallet, + data.description, + data.onchainwallet, onchainaddress, - lnbitswallet, + data.lnbitswallet, payment_request, payment_hash, - webhook, - completelink, - completelinktext, - time, - amount, + data.webhook, + data.completelink, + data.completelinktext, + data.time, + data.amount, 0, ), ) diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py index a7bfa14f..8730809b 100644 --- a/lnbits/extensions/satspay/models.py +++ b/lnbits/extensions/satspay/models.py @@ -1,9 +1,19 @@ from sqlite3 import Row -from typing import NamedTuple +from fastapi.param_functions import Query +from pydantic import BaseModel import time +class CreateCharge(BaseModel): + onchainwallet: str = Query(None) + lnbitswallet: str = Query(None) + description: str = Query(...) + webhook: str = Query(None) + completelink: str = Query(None) + completelinktext: str = Query(None) + time: int = Query(..., ge=1) + amount: int = Query(..., ge=1) -class Charges(NamedTuple): +class Charges(BaseModel): id: str user: str description: str diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html index 526af7f3..1a7ba5e4 100644 --- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -90,7 +90,7 @@
Curl example
curl -X GET {{ request.url_root }}api/v1/charge/<charge_id> - -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" + -H "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -113,7 +113,7 @@
Curl example
curl -X GET {{ request.url_root }}api/v1/charges -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + user.wallets[0].inkey }}" @@ -139,7 +139,7 @@ curl -X DELETE {{ request.url_root }}api/v1/charge/<charge_id> -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" + user.wallets[0].adminkey }}" @@ -162,7 +162,7 @@ curl -X GET {{ request.url_root }}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + user.wallets[0].inkey }}" diff --git a/lnbits/extensions/satspay/views.py b/lnbits/extensions/satspay/views.py index 2c99a925..c1be381c 100644 --- a/lnbits/extensions/satspay/views.py +++ b/lnbits/extensions/satspay/views.py @@ -1,22 +1,29 @@ -from quart import g, abort, render_template, jsonify +from fastapi.param_functions import Depends +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from lnbits.core.models import User +from lnbits.core.crud import get_wallet +from lnbits.decorators import check_user_exists from http import HTTPStatus -from lnbits.decorators import check_user_exists, validate_uuids +from fastapi.templating import Jinja2Templates -from . import satspay_ext +from . import satspay_ext, satspay_renderer from .crud import get_charge +templates = Jinja2Templates(directory="templates") -@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.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return satspay_renderer().TemplateResponse("satspay/index.html", {"request": request,"user": user.dict()}) -@satspay_ext.route("/") -async def display(charge_id): - charge = await get_charge(charge_id) or abort( - HTTPStatus.NOT_FOUND, "Charge link does not exist." - ) - return await render_template("satspay/display.html", charge=charge) +@satspay_ext.get("/{charge_id}", response_class=HTMLResponse) +async def display(request: Request, charge_id): + charge = await get_charge(charge_id) + if not charge: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Charge link does not exist." + ) + return satspay_renderer().TemplateResponse("satspay/display.html", {"request": request, "charge": charge}) diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py index 9440312a..85590e5e 100644 --- a/lnbits/extensions/satspay/views_api.py +++ b/lnbits/extensions/satspay/views_api.py @@ -1,13 +1,21 @@ import hashlib -from quart import g, jsonify, url_for + from http import HTTPStatus import httpx +from fastapi import Query +from fastapi.params import Depends + +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse # type: ignore + from lnbits.core.crud import get_user -from lnbits.decorators import api_check_wallet_key, api_validate_post_request +from lnbits.decorators import WalletTypeInfo, get_key_type from lnbits.extensions.satspay import satspay_ext +from .models import CreateCharge from .crud import ( create_charge, update_charge, @@ -20,94 +28,78 @@ from .crud import ( #############################CHARGES########################## -@satspay_ext.route("/api/v1/charge", methods=["POST"]) -@satspay_ext.route("/api/v1/charge/", methods=["PUT"]) -@api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "onchainwallet": {"type": "string"}, - "lnbitswallet": {"type": "string"}, - "description": {"type": "string", "empty": False, "required": True}, - "webhook": {"type": "string"}, - "completelink": {"type": "string"}, - "completelinktext": {"type": "string"}, - "time": {"type": "integer", "min": 1, "required": True}, - "amount": {"type": "integer", "min": 1, "required": True}, - } -) -async def api_charge_create_or_update(charge_id=None): +@satspay_ext.post("/api/v1/charge") +@satspay_ext.put("/api/v1/charge/{charge_id}") + +async def api_charge_create_or_update(data: CreateCharge, wallet: WalletTypeInfo = Depends(get_key_type), charge_id=None): if not charge_id: - charge = await create_charge(user=g.wallet.user, **g.data) - return jsonify(charge._asdict()), HTTPStatus.CREATED + charge = await create_charge(user=wallet.wallet.user, **data) + return charge.dict() else: - charge = await update_charge(charge_id=charge_id, **g.data) - return jsonify(charge._asdict()), HTTPStatus.OK + charge = await update_charge(charge_id=charge_id, **data) + return charge.dict() -@satspay_ext.route("/api/v1/charges", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_charges_retrieve(): +@satspay_ext.get("/api/v1/charges") +async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): try: - return ( - jsonify( - [ + return [ { - **charge._asdict(), + **charge.dict(), **{"time_elapsed": charge.time_elapsed}, **{"paid": charge.paid}, } - for charge in await get_charges(g.wallet.user) + for charge in await get_charges(wallet.wallet.user) ] - ), - HTTPStatus.OK, - ) except: return "" -@satspay_ext.route("/api/v1/charge/", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_charge_retrieve(charge_id): +@satspay_ext.get("/api/v1/charge/{charge_id}") +async def api_charge_retrieve(charge_id, wallet: WalletTypeInfo = Depends(get_key_type)): charge = await get_charge(charge_id) if not charge: - return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Charge does not exist." + ) - return ( - jsonify( - { - **charge._asdict(), + return { + **charge.dict(), **{"time_elapsed": charge.time_elapsed}, **{"paid": charge.paid}, } - ), - HTTPStatus.OK, - ) -@satspay_ext.route("/api/v1/charge/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_charge_delete(charge_id): +@satspay_ext.delete("/api/v1/charge/{charge_id}") +async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_type)): charge = await get_charge(charge_id) if not charge: - return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Charge does not exist." + ) await delete_charge(charge_id) - - return "", HTTPStatus.NO_CONTENT + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) #############################BALANCE########################## -@satspay_ext.route("/api/v1/charges/balance/", methods=["GET"]) +@satspay_ext.get("/api/v1/charges/balance/{charge_id}") async def api_charges_balance(charge_id): charge = await check_address_balance(charge_id) if not charge: - return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Charge does not exist." + ) + if charge.paid and charge.webhook: async with httpx.AsyncClient() as client: try: @@ -130,28 +122,21 @@ async def api_charges_balance(charge_id): ) except AssertionError: charge.webhook = None - return jsonify(charge._asdict()), HTTPStatus.OK + return charge.dict() #############################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.put("/api/v1/mempool") +async def api_update_mempool(endpoint: str = Query(...), wallet: WalletTypeInfo = Depends(get_key_type)): + mempool = await update_mempool(endpoint, user=wallet.wallet.user) + return mempool.dict() -@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) +@satspay_ext.route("/api/v1/mempool") +async def api_get_mempool(wallet: WalletTypeInfo = Depends(get_key_type)): + mempool = await get_mempool(wallet.wallet.user) if not mempool: - mempool = await create_mempool(user=g.wallet.user) - return jsonify(mempool._asdict()), HTTPStatus.OK + mempool = await create_mempool(user=wallet.wallet.user) + return mempool.dict() From ec4117a5f45739443d7630f67a1141dd3c1d4779 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 14 Oct 2021 22:30:29 +0100 Subject: [PATCH 5/6] satspay done --- lnbits/extensions/satspay/crud.py | 9 --------- lnbits/extensions/satspay/models.py | 15 ++++++++------- .../satspay/templates/satspay/_api_docs.html | 4 ++-- .../satspay/templates/satspay/display.html | 3 ++- .../satspay/templates/satspay/index.html | 4 +++- lnbits/extensions/satspay/views.py | 1 + lnbits/extensions/satspay/views_api.py | 4 ++-- 7 files changed, 18 insertions(+), 22 deletions(-) diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py index fab0406f..e707dc00 100644 --- a/lnbits/extensions/satspay/crud.py +++ b/lnbits/extensions/satspay/crud.py @@ -17,15 +17,6 @@ from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool async def create_charge( user: str, data: CreateCharge - # user: str, - # description: str = None, - # onchainwallet: Optional[str] = None, - # lnbitswallet: Optional[str] = None, - # webhook: Optional[str] = None, - # completelink: Optional[str] = None, - # completelinktext: Optional[str] = "Back to Merchant", - # time: Optional[int] = None, - # amount: Optional[int] = None, ) -> Charges: charge_id = urlsafe_short_hash() if data.onchainwallet: diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py index 8730809b..4cf3efad 100644 --- a/lnbits/extensions/satspay/models.py +++ b/lnbits/extensions/satspay/models.py @@ -1,4 +1,5 @@ from sqlite3 import Row +from typing import Optional from fastapi.param_functions import Query from pydantic import BaseModel import time @@ -16,15 +17,15 @@ class CreateCharge(BaseModel): class Charges(BaseModel): id: str user: str - description: str - onchainwallet: str - onchainaddress: str - lnbitswallet: str + description: Optional[str] + onchainwallet: Optional[str] + onchainaddress: Optional[str] + lnbitswallet: Optional[str] payment_request: str payment_hash: str - webhook: str - completelink: str - completelinktext: str + webhook: Optional[str] + completelink: Optional[str] + completelinktext: Optional[str] = "Back to Merchant" time: int amount: int balance: int diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html index 1a7ba5e4..af95cbf2 100644 --- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -37,7 +37,7 @@ "description": <string>, "webhook":<string>, "time": <integer>, "amount": <integer>, "lnbitswallet": <string, lnbits_wallet_id>}' -H "Content-type: - application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}" + application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" @@ -65,7 +65,7 @@ "description": <string>, "webhook":<string>, "time": <integer>, "amount": <integer>, "lnbitswallet": <string, lnbits_wallet_id>}' -H "Content-type: - application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}" + application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html index b3386074..5b0282b6 100644 --- a/lnbits/extensions/satspay/templates/satspay/display.html +++ b/lnbits/extensions/satspay/templates/satspay/display.html @@ -207,7 +207,7 @@ {% endblock %} {% block scripts %} - +