diff --git a/lnbits/extensions/satspay/README.md b/lnbits/extensions/satspay/README.md new file mode 100644 index 000000000..5f7511caf --- /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 000000000..7023f7a9d --- /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 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..5779592a8 --- /dev/null +++ b/lnbits/extensions/satspay/crud.py @@ -0,0 +1,96 @@ +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_derive_address, get_mempool + +import time + +###############CHARGES########################## + + +async def create_charge(user: str, description: Optional[str] = None, onchainwallet: Optional[str] = None, lnbitswallet: Optional[str] = None, webhook: Optional[str] = None, time: Optional[int] = None, amount: Optional[int] = None) -> Charges: + charge_id = urlsafe_short_hash() + if onchainwallet: + wallet = await get_watch_wallet(onchainwallet) + onchainaddress = await get_derive_address(onchainwallet, wallet[4] + 1) + 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 charges ( + id, + user, + description, + onchainwallet, + onchainaddress, + lnbitswallet, + payment_request, + payment_hash, + webhook, + time, + amount, + balance, + paid + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (charge_id, user, description, onchainwallet, onchainaddress, lnbitswallet, payment_request, payment_hash, webhook, time, amount, 0, False), + ) + 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 charges SET {q} WHERE id = ?", (*kwargs.values(), wallet_id)) + row = await db.fetchone("SELECT * FROM charges WHERE id = ?", (wallet_id,)) + return Charges.from_row(row) if row else None + + +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,)) + 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(charge_id: str) -> List[Charges]: + charge = await get_charge(charge_id) + 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 (charge.balance + respAmount) >= charge.balance: + return await update_charge(charge_id = charge_id, balance = (charge.balance + respAmount), paid = True) + else: + return await update_charge(charge_id = charge_id, balance = (charge.balance + respAmount), paid = False) + 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.balance, paid = True) + row = await db.fetchone("SELECT * FROM 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..044eed13b --- /dev/null +++ b/lnbits/extensions/satspay/migrations.py @@ -0,0 +1,26 @@ +async def m001_initial(db): + """ + Initial wallet table. + """ + + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS 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, + time INTEGER, + amount INTEGER, + balance INTEGER DEFAULT 0, + paid BOOLEAN, + timestamp TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) + ); + """ + ) \ 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 000000000..013dc1d7f --- /dev/null +++ b/lnbits/extensions/satspay/models.py @@ -0,0 +1,22 @@ +from sqlite3 import Row +from typing import NamedTuple + +class Charges(NamedTuple): + id: str + user: str + description: str + onchainwallet: str + onchainaddress: str + lnbitswallet: str + payment_request: str + payment_hash: str + webhook: str + time: str + amount: int + balance: int + paid: bool + timestamp: 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/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html new file mode 100644 index 000000000..b4bc201ea --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -0,0 +1,156 @@ + + +

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": <invoice_key>}
+
Body (application/json)
+ {"description": <string> "amount": <integer>} +
+ Returns 201 CREATED (application/json) +
+ { + "deliveryId": <string>, + "description": <string>, + "webhookId": <string>, + "originalDeliveryId": <string>, + "isRedelivery": <boolean>, + "type": <string>, + "timestamp": <int>, + "paytime": <int>, + "storeId": <string>, + "invoiceId": <string>, + "manuallyMarked": <boolean>, + "overPaid": <boolean>, + "afterExpiration": <boolean>, + "partiallyPaid": <boolean> + }
"type" can be InvoiceReceivedPayment, InvoicePaidInFull, InvoiceExpired, InvoiceConfirmed, and InvoiceInvalid
+
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/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html new file mode 100644 index 000000000..c0773e33e --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/display.html @@ -0,0 +1,74 @@ +{% extends "public.html" %} {% block page %} + +
+ + +
Our Changing Planet
+
by John Doe
+
+ + + + + + + + + + + +
+ Copy 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..a4a2a4318 --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -0,0 +1,643 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +{% raw %} + New charge + + + + + +
+
+
Charges
+
+
+ + + + +
+
+ + + + + + {% endraw %} + + + + + +
+
+ + + + +
+ + + +
+ + +
+ LNbits satspay Extension +
+
+ + + + {% include "satspay/_api_docs.html" %} + + +
+
+ + + + + + + + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + + +
+
+
+ +
+ +
+
+
+ + + +
+ + + +
+
+ + +
+
+ Update Paylink + Create Paylink + 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..1be0d2669 --- /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): + charge = get_charge(charge_id) or abort(HTTPStatus.NOT_FOUND, "Charge link does not exist.") + + return await render_template("satspay/display.html", charge=charge) \ 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 000000000..b99ab6322 --- /dev/null +++ b/lnbits/extensions/satspay/views_api.py @@ -0,0 +1,112 @@ +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, + check_address_balance, +) + +#############################CHARGES########################## +@satspay_ext.route("/api/v1/charges/balance/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_charges_balance(charge_id): + + charge = await check_address_balance(charge_id) + if not charge: + return ( + jsonify(""), + HTTPStatus.OK + ) + else: + return jsonify(charge._asdict()), HTTPStatus.OK + +@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={ + "onchainwallet": {"type": "string"}, + "lnbitswallet": {"type": "string"}, + "description": {"type": "string", "empty": False, "required": True}, + "webhook": {"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