diff --git a/.env.example b/.env.example index 704eeb8..c064292 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ FLASK_APP=lnbits FLASK_ENV=development +LND_API_ENDPOINT=https://mylnd.io/rest/ +LND_ADMIN_MACAROON=LND_ADMIN_MACAROON + LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/ LNTXBOT_ADMIN_KEY=LNTXBOT_ADMIN_KEY LNTXBOT_INVOICE_KEY=LNTXBOT_INVOICE_KEY diff --git a/lnbits/__init__.py b/lnbits/__init__.py index 2ae6977..259b949 100644 --- a/lnbits/__init__.py +++ b/lnbits/__init__.py @@ -37,12 +37,13 @@ def deletewallet(): with Database() as db: db.execute( """ - UPDATE wallets AS w SET - user = 'del:' || w.user, - adminkey = 'del:' || w.adminkey, - inkey = 'del:' || w.inkey + UPDATE wallets AS w + SET + user = 'del:' || w.user, + adminkey = 'del:' || w.adminkey, + inkey = 'del:' || w.inkey WHERE id = ? AND user = ? - """, + """, (wallet_id, user_id), ) @@ -73,19 +74,19 @@ def lnurlwallet(): withdraw_res = LnurlWithdrawResponse(**data) - invoice = WALLET.create_invoice(withdraw_res.max_sats, "lnbits lnurl funding").json() - payment_hash = invoice["payment_hash"] + _, pay_hash, pay_req = WALLET.create_invoice(withdraw_res.max_sats, "LNbits lnurl funding") r = requests.get( withdraw_res.callback.base, - params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": invoice["pay_req"]}}, + params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": pay_req}}, ) + if not r.ok: return redirect(url_for("home")) data = json.loads(r.text) for i in range(10): - r = WALLET.get_invoice_status(payment_hash) + r = WALLET.get_invoice_status(pay_hash).raw_response if not r.ok: continue @@ -106,7 +107,7 @@ def lnurlwallet(): ) db.execute( "INSERT INTO apipayments (payhash, amount, wallet, pending, memo) VALUES (?, ?, ?, 0, ?)", - (payment_hash, withdraw_res.max_sats * 1000, wallet_id, "lnbits lnurl funding",), + (pay_hash, withdraw_res.max_sats * 1000, wallet_id, "LNbits lnurl funding",), ) return redirect(url_for("wallet", usr=user_id, wal=wallet_id)) @@ -136,8 +137,8 @@ def wallet(): db.execute( """ - INSERT OR IGNORE INTO accounts (id) VALUES (?) - """, + INSERT OR IGNORE INTO accounts (id) VALUES (?) + """, (usr,), ) @@ -155,9 +156,9 @@ def wallet(): wallet_id = uuid.uuid4().hex db.execute( """ - INSERT INTO wallets (id, name, user, adminkey, inkey) - VALUES (?, ?, ?, ?, ?) - """, + INSERT INTO wallets (id, name, user, adminkey, inkey) + VALUES (?, ?, ?, ?, ?) + """, (wallet_id, wallet_name, usr, uuid.uuid4().hex, uuid.uuid4().hex), ) @@ -167,13 +168,13 @@ def wallet(): # ------------------------------------------------------------ db.execute( """ - INSERT OR REPLACE INTO wallets (id, user, name, adminkey, inkey) - VALUES (?, ?, - coalesce((SELECT name FROM wallets WHERE id = ?), ?), - coalesce((SELECT adminkey FROM wallets WHERE id = ?), ?), - coalesce((SELECT inkey FROM wallets WHERE id = ?), ?) - ) - """, + INSERT OR REPLACE INTO wallets (id, user, name, adminkey, inkey) + VALUES (?, ?, + coalesce((SELECT name FROM wallets WHERE id = ?), ?), + coalesce((SELECT adminkey FROM wallets WHERE id = ?), ?), + coalesce((SELECT inkey FROM wallets WHERE id = ?), ?) + ) + """, ( wallet_id, usr, @@ -191,24 +192,22 @@ def wallet(): wallet = db.fetchone( """ - SELECT - coalesce( - (SELECT balance/1000 FROM balances WHERE wallet = wallets.id), - 0 - ) * ? AS balance, - * - FROM wallets - WHERE user = ? AND id = ? - """, + SELECT + coalesce((SELECT balance/1000 FROM balances WHERE wallet = wallets.id), 0) * ? AS balance, + * + FROM wallets + WHERE user = ? AND id = ? + """, (1 - FEE_RESERVE, usr, wallet_id), ) transactions = db.fetchall( """ - SELECT * FROM apipayments - WHERE wallet = ? AND pending = 0 - ORDER BY time - """, + SELECT * + FROM apipayments + WHERE wallet = ? AND pending = 0 + ORDER BY time + """, (wallet_id,), ) @@ -245,39 +244,36 @@ def api_invoices(): if not wallet: return jsonify({"ERROR": "NO KEY"}), 200 - r = WALLET.create_invoice(postedjson["value"], postedjson["memo"]) - if not r.ok or r.json().get("error"): - return jsonify({"ERROR": "UNEXPECTED BACKEND ERROR"}), 500 + r, pay_hash, pay_req = WALLET.create_invoice(postedjson["value"], postedjson["memo"]) - data = r.json() + if not r.ok or "error" in r.json(): + return jsonify({"ERROR": "UNEXPECTED BACKEND ERROR"}), 500 - pay_req = data["pay_req"] - payment_hash = data["payment_hash"] amount_msat = int(postedjson["value"]) * 1000 db.execute( "INSERT INTO apipayments (payhash, amount, wallet, pending, memo) VALUES (?, ?, ?, 1, ?)", - (payment_hash, amount_msat, wallet["id"], postedjson["memo"],), + (pay_hash, amount_msat, wallet["id"], postedjson["memo"],), ) - return jsonify({"pay_req": pay_req, "payment_hash": payment_hash}), 200 + return jsonify({"pay_req": pay_req, "payment_hash": pay_hash}), 200 @app.route("/v1/channels/transactions", methods=["GET", "POST"]) def api_transactions(): if request.headers["Content-Type"] != "application/json": - return jsonify({"ERROR": "MUST BE JSON"}), 200 + return jsonify({"ERROR": "MUST BE JSON"}), 400 data = request.json if "payment_request" not in data: - return jsonify({"ERROR": "NO PAY REQ"}), 200 + return jsonify({"ERROR": "NO PAY REQ"}), 400 with Database() as db: wallet = db.fetchone("SELECT id FROM wallets WHERE adminkey = ?", (request.headers["Grpc-Metadata-macaroon"],)) if not wallet: - return jsonify({"ERROR": "BAD AUTH"}), 200 + return jsonify({"ERROR": "BAD AUTH"}), 401 # decode the invoice invoice = bolt11.decode(data["payment_request"]) @@ -331,16 +327,17 @@ def api_transactions(): @app.route("/v1/invoice/", methods=["GET"]) def api_checkinvoice(payhash): if request.headers["Content-Type"] != "application/json": - return jsonify({"ERROR": "MUST BE JSON"}), 200 + return jsonify({"ERROR": "MUST BE JSON"}), 400 with Database() as db: payment = db.fetchone( """ - SELECT pending FROM apipayments - INNER JOIN wallets AS w ON apipayments.wallet = w.id - WHERE payhash = ? - AND (w.adminkey = ? OR w.inkey = ?) - """, + SELECT pending + FROM apipayments + INNER JOIN wallets AS w ON apipayments.wallet = w.id + WHERE payhash = ? + AND (w.adminkey = ? OR w.inkey = ?) + """, (payhash, request.headers["Grpc-Metadata-macaroon"], request.headers["Grpc-Metadata-macaroon"]), ) @@ -350,14 +347,9 @@ def api_checkinvoice(payhash): if not payment["pending"]: # pending return jsonify({"PAID": "TRUE"}), 200 - r = WALLET.get_invoice_status(payhash) - if not r.ok or r.json().get("error"): + if not WALLET.get_invoice_status(payhash).settled: return jsonify({"PAID": "FALSE"}), 200 - data = r.json() - if "preimage" not in data: - return jsonify({"PAID": "FALSE"}), 400 - db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,)) return jsonify({"PAID": "TRUE"}), 200 @@ -368,30 +360,30 @@ def api_checkpending(): for pendingtx in db.fetchall( """ SELECT - payhash, - CASE - WHEN amount < 0 THEN 'send' - ELSE 'recv' - END AS kind + payhash, + CASE + WHEN amount < 0 THEN 'send' + ELSE 'recv' + END AS kind FROM apipayments INNER JOIN wallets ON apipayments.wallet = wallets.id WHERE time > strftime('%s', 'now') - 86400 - AND pending = 1 - AND (adminkey = ? OR inkey = ?) - """, + AND pending = 1 + AND (adminkey = ? OR inkey = ?) + """, (request.headers["Grpc-Metadata-macaroon"], request.headers["Grpc-Metadata-macaroon"]), ): payhash = pendingtx["payhash"] kind = pendingtx["kind"] if kind == "send": - status = WALLET.get_final_payment_status(payhash) - if status == "complete": + payment_complete = WALLET.get_payment_status(payhash).settled + if payment_complete: db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,)) - elif status == "failed": + elif payment_complete is False: db.execute("DELETE FROM apipayments WHERE payhash = ?", (payhash,)) - elif kind == "recv": - if WALLET.is_invoice_paid(payhash): - db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,)) + elif kind == "recv" and WALLET.get_invoice_status(payhash).settled: + db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,)) + return "" diff --git a/lnbits/settings.py b/lnbits/settings.py index f714a86..7448724 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -1,6 +1,6 @@ import os -from .wallets import LntxbotWallet # OR LndHubWallet +from .wallets import LntxbotWallet # OR LndWallet WALLET = LntxbotWallet( @@ -10,7 +10,7 @@ WALLET = LntxbotWallet( ) # OR -# WALLET = LndHubWallet(uri=os.getenv("LNDHUB_URI")) +# WALLET = LndWallet(endpoint=os.getenv("LND_API_ENDPOINT"), admin_macaroon=os.getenv("LND_ADMIN_MACAROON")) LNBITS_PATH = os.path.dirname(os.path.realpath(__file__)) DATABASE_PATH = os.getenv("DATABASE_PATH") or os.path.join(LNBITS_PATH, "data", "database.sqlite3") diff --git a/lnbits/wallets.py b/lnbits/wallets.py deleted file mode 100644 index 891abba..0000000 --- a/lnbits/wallets.py +++ /dev/null @@ -1,65 +0,0 @@ -import requests - -from abc import ABC, abstractmethod -from requests import Response - - -class WalletResponse(Response): - """TODO: normalize different wallet responses - """ - - -class Wallet(ABC): - @abstractmethod - def create_invoice(self, amount: int, memo: str = "") -> WalletResponse: - pass - - @abstractmethod - def pay_invoice(self, bolt11: str) -> WalletResponse: - pass - - @abstractmethod - def get_invoice_status(self, payment_hash: str, wait: bool = True) -> WalletResponse: - pass - - -class LndHubWallet(Wallet): - def __init__(self, *, uri: str): - raise NotImplementedError - - -class LntxbotWallet(Wallet): - def __init__(self, *, endpoint: str, admin_key: str, invoice_key: str) -> WalletResponse: - self.endpoint = endpoint - self.auth_admin = {"Authorization": f"Basic {admin_key}"} - self.auth_invoice = {"Authorization": f"Basic {invoice_key}"} - - def create_invoice(self, amount: int, memo: str = "") -> WalletResponse: - return requests.post( - url=f"{self.endpoint}/addinvoice", headers=self.auth_invoice, json={"amt": str(amount), "memo": memo} - ) - - def pay_invoice(self, bolt11: str) -> WalletResponse: - return requests.post(url=f"{self.endpoint}/payinvoice", headers=self.auth_admin, json={"invoice": bolt11}) - - def get_invoice_status(self, payment_hash: str, wait: bool = True) -> Response: - wait = 'true' if wait else 'false' - return requests.post(url=f"{self.endpoint}/invoicestatus/{payment_hash}?wait={wait}", headers=self.auth_invoice) - - def is_invoice_paid(self, payment_hash: str) -> False: - r = self.get_invoice_status(payment_hash) - if not r.ok or r.json().get('error'): - return False - - data = r.json() - if "preimage" not in data or not data["preimage"]: - return False - - return True - - def get_final_payment_status(self, payment_hash: str) -> str: - r = requests.post(url=f"{self.endpoint}/paymentstatus/{payment_hash}", headers=self.auth_invoice) - if not r.ok: - return "unknown" - - return r.json().get('status', 'unknown') diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py new file mode 100644 index 0000000..d9061a9 --- /dev/null +++ b/lnbits/wallets/__init__.py @@ -0,0 +1,4 @@ +# flake8: noqa + +from .lnd import LndWallet +from .lntxbot import LntxbotWallet diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py new file mode 100644 index 0000000..14c2b16 --- /dev/null +++ b/lnbits/wallets/base.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from requests import Response +from typing import NamedTuple, Optional + + +class InvoiceResponse(NamedTuple): + raw_response: Response + payment_hash: Optional[str] = None + payment_request: Optional[str] = None + + +class TxStatus(NamedTuple): + raw_response: Response + settled: Optional[bool] = None + + +class Wallet(ABC): + @abstractmethod + def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: + pass + + @abstractmethod + def pay_invoice(self, bolt11: str) -> Response: + pass + + @abstractmethod + def get_invoice_status(self, payment_hash: str, wait: bool = True) -> TxStatus: + pass + + @abstractmethod + def get_payment_status(self, payment_hash: str) -> TxStatus: + pass diff --git a/lnbits/wallets/lnd.py b/lnbits/wallets/lnd.py new file mode 100644 index 0000000..33f687e --- /dev/null +++ b/lnbits/wallets/lnd.py @@ -0,0 +1,48 @@ +from requests import Response, get, post + +from .base import InvoiceResponse, TxStatus, Wallet + + +class LndWallet(Wallet): + """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" + + def __init__(self, *, endpoint: str, admin_macaroon: str): + self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint + self.auth_admin = {"Grpc-Metadata-macaroon": admin_macaroon} + + def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: + payment_hash, payment_request = None, None + r = post( + url=f"{self.endpoint}/v1/invoices", + headers=self.auth_admin, + json={"value": f"{amount}", "description_hash": memo}, # , "private": True}, + ) + + if r.ok: + data = r.json() + payment_hash, payment_request = data["r_hash"], data["payment_request"] + + return InvoiceResponse(r, payment_hash, payment_request) + + def pay_invoice(self, bolt11: str) -> Response: + raise NotImplementedError + + def get_invoice_status(self, payment_hash: str, wait: bool = True) -> TxStatus: + r = get(url=f"{self.endpoint}/v1/invoice", headers=self.auth_admin, params={"r_hash": payment_hash}) + + if not r.ok: + return TxStatus(r, None) + + return TxStatus(r, r.json()["settled"]) + + def get_payment_status(self, payment_hash: str) -> TxStatus: + r = get(url=f"{self.endpoint}/v1/payments", headers=self.auth_admin, params={"include_incomplete": True}) + + if not r.ok: + return TxStatus(r, None) + + payments = [p for p in r.json()["payments"] if p["payment_hash"] == payment_hash] + payment = payments[0] if payments else None + + # check payment.status: https://api.lightning.community/rest/index.html?python#peersynctype + return TxStatus(r, {0: None, 1: None, 2: True, 3: False}[payment["status"]] if payment else None) diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py new file mode 100644 index 0000000..256866b --- /dev/null +++ b/lnbits/wallets/lntxbot.py @@ -0,0 +1,47 @@ +from requests import Response, post + +from .base import InvoiceResponse, TxStatus, Wallet + + +class LntxbotWallet(Wallet): + """https://github.com/fiatjaf/lntxbot/blob/master/api.go""" + + def __init__(self, *, endpoint: str, admin_key: str, invoice_key: str): + self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint + self.auth_admin = {"Authorization": f"Basic {admin_key}"} + self.auth_invoice = {"Authorization": f"Basic {invoice_key}"} + + def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse: + payment_hash, payment_request = None, None + r = post(url=f"{self.endpoint}/addinvoice", headers=self.auth_invoice, json={"amt": str(amount), "memo": memo}) + + if r.ok: + data = r.json() + payment_hash, payment_request = data["payment_hash"], data["pay_req"] + + return InvoiceResponse(r, payment_hash, payment_request) + + def pay_invoice(self, bolt11: str) -> Response: + return post(url=f"{self.endpoint}/payinvoice", headers=self.auth_admin, json={"invoice": bolt11}) + + def get_invoice_status(self, payment_hash: str, wait: bool = True) -> TxStatus: + wait = "true" if wait else "false" + r = post(url=f"{self.endpoint}/invoicestatus/{payment_hash}?wait={wait}", headers=self.auth_invoice) + data = r.json() + + if not r.ok or "error" in data: + return TxStatus(r, None) + + if "preimage" not in data or not data["preimage"]: + return TxStatus(r, False) + + return TxStatus(r, True) + + def get_payment_status(self, payment_hash: str) -> TxStatus: + r = post(url=f"{self.endpoint}/paymentstatus/{payment_hash}", headers=self.auth_invoice) + data = r.json() + + if not r.ok or "error" in data: + return TxStatus(r, None) + + return TxStatus(r, {"complete": True, "failed": False, "unknown": None}[data.get("status", "unknown")])