From 43f36afeb0c1660aae7c03facc61b8f5baacd20b Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 14 Oct 2021 10:02:02 +0100 Subject: [PATCH] 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 000000000..d52547aea --- /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 000000000..4bdaa2b63 --- /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 000000000..beb0071cb --- /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 000000000..56cabdbe3 --- /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 000000000..87446c800 --- /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 000000000..a7bfa14f3 --- /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 000000000..526af7f37 --- /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 000000000..b3386074e --- /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 000000000..f3566c7c4 --- /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 000000000..2c99a9258 --- /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 000000000..9440312ae --- /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