From d862b16ee6fdda667e83d4e8c57aadf09fe0489f Mon Sep 17 00:00:00 2001 From: Eneko Illarramendi Date: Sat, 7 Mar 2020 22:27:00 +0100 Subject: [PATCH] refactor: "payments" is the name, and API updates --- lnbits/__init__.py | 94 +--------- lnbits/core/crud.py | 52 +++--- lnbits/core/models.py | 38 ++-- lnbits/core/static/js/wallet.js | 166 +++++++++++++----- lnbits/core/templates/core/wallet.html | 31 ++-- lnbits/core/views.py | 8 +- lnbits/core/views_api.py | 100 ++++++++--- lnbits/helpers.py | 3 +- lnbits/static/js/base.js | 53 +++--- .../static/vendor/moment@2.24.0/moment.min.js | 1 + lnbits/templates/macros.jinja | 1 - lnbits/wallets/base.py | 15 +- lnbits/wallets/lnd.py | 14 +- lnbits/wallets/lnpay.py | 20 +-- lnbits/wallets/lntxbot.py | 25 +-- lnbits/wallets/opennode.py | 14 +- 16 files changed, 355 insertions(+), 280 deletions(-) create mode 100644 lnbits/static/vendor/moment@2.24.0/moment.min.js diff --git a/lnbits/__init__.py b/lnbits/__init__.py index 2f59c579e..d3179cc9b 100644 --- a/lnbits/__init__.py +++ b/lnbits/__init__.py @@ -3,18 +3,16 @@ import json import requests import uuid -from flask import g, Flask, jsonify, redirect, render_template, request, url_for +from flask import Flask, redirect, render_template, request, url_for from flask_assets import Environment, Bundle from flask_compress import Compress from flask_talisman import Talisman from lnurl import Lnurl, LnurlWithdrawResponse -from . import bolt11 from .core import core_app -from .decorators import api_validate_post_request from .db import init_databases, open_db from .helpers import ExtensionManager, megajson -from .settings import WALLET, DEFAULT_USER_WALLET_NAME, FEE_RESERVE +from .settings import WALLET, DEFAULT_USER_WALLET_NAME app = Flask(__name__) @@ -147,93 +145,5 @@ def lnurlwallet(): return redirect(url_for("wallet", usr=user_id, wal=wallet_id)) -@app.route("/api/v1/channels/transactions", methods=["GET", "POST"]) -@api_validate_post_request(required_params=["payment_request"]) -def api_transactions(): - - with open_db() as db: - wallet = db.fetchone("SELECT id FROM wallets WHERE adminkey = ?", (request.headers["Grpc-Metadata-macaroon"],)) - - if not wallet: - return jsonify({"message": "BAD AUTH"}), 401 - - # decode the invoice - invoice = bolt11.decode(g.data["payment_request"]) - if invoice.amount_msat == 0: - return jsonify({"message": "AMOUNTLESS INVOICES NOT SUPPORTED"}), 400 - - # insert the payment - db.execute( - "INSERT OR IGNORE INTO apipayments (payhash, amount, fee, wallet, pending, memo) VALUES (?, ?, ?, ?, 1, ?)", - ( - invoice.payment_hash, - -int(invoice.amount_msat), - -int(invoice.amount_msat) * FEE_RESERVE, - wallet["id"], - invoice.description, - ), - ) - - # check balance - balance = db.fetchone("SELECT balance/1000 FROM balances WHERE wallet = ?", (wallet["id"],))[0] - if balance < 0: - db.execute("DELETE FROM apipayments WHERE payhash = ? AND wallet = ?", (invoice.payment_hash, wallet["id"])) - return jsonify({"message": "INSUFFICIENT BALANCE"}), 403 - - # check if the invoice is an internal one - if db.fetchone("SELECT count(*) FROM apipayments WHERE payhash = ?", (invoice.payment_hash,))[0] == 2: - # internal. mark both sides as fulfilled. - db.execute("UPDATE apipayments SET pending = 0, fee = 0 WHERE payhash = ?", (invoice.payment_hash,)) - else: - # actually send the payment - r = WALLET.pay_invoice(g.data["payment_request"]) - - if not r.raw_response.ok or r.failed: - return jsonify({"message": "UNEXPECTED PAYMENT ERROR"}), 500 - - # payment went through, not pending anymore, save actual fees - db.execute( - "UPDATE apipayments SET pending = 0, fee = ? WHERE payhash = ? AND wallet = ?", - (r.fee_msat, invoice.payment_hash, wallet["id"]), - ) - - return jsonify({"PAID": "TRUE", "payment_hash": invoice.payment_hash}), 200 - - -@app.route("/api/v1/checkpending", methods=["POST"]) -def api_checkpending(): - with open_db() as db: - for pendingtx in db.fetchall( - """ - SELECT - 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 = ?) - """, - (request.headers["Grpc-Metadata-macaroon"], request.headers["Grpc-Metadata-macaroon"]), - ): - payhash = pendingtx["payhash"] - kind = pendingtx["kind"] - - if kind == "send": - payment_complete = WALLET.get_payment_status(payhash).settled - if payment_complete: - db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,)) - elif payment_complete is False: - db.execute("DELETE FROM apipayments WHERE payhash = ?", (payhash,)) - - elif kind == "recv" and WALLET.get_invoice_status(payhash).settled: - db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,)) - - return "" - - if __name__ == '__main__': app.run() diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 6fd7ba1d1..51431c816 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -4,7 +4,7 @@ from lnbits.db import open_db from lnbits.settings import DEFAULT_USER_WALLET_NAME, FEE_RESERVE from typing import List, Optional -from .models import User, Transaction, Wallet +from .models import User, Wallet, Payment # accounts @@ -34,7 +34,7 @@ def get_user(user_id: str) -> Optional[User]: extensions = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,)) wallets = db.fetchall( """ - SELECT *, COALESCE((SELECT balance/1000 FROM balances WHERE wallet = wallets.id), 0) * ? AS balance + SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) * ? AS balance_msat FROM wallets WHERE user = ? """, @@ -96,7 +96,7 @@ def get_wallet(wallet_id: str) -> Optional[Wallet]: with open_db() as db: row = db.fetchone( """ - SELECT *, COALESCE((SELECT balance/1000 FROM balances WHERE wallet = wallets.id), 0) * ? AS balance + SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) * ? AS balance_msat FROM wallets WHERE id = ? """, @@ -111,7 +111,7 @@ def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]: check_field = "adminkey" if key_type == "admin" else "inkey" row = db.fetchone( f""" - SELECT *, COALESCE((SELECT balance/1000 FROM balances WHERE wallet = wallets.id), 0) * ? AS balance + SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) * ? AS balance_msat FROM wallets WHERE {check_field} = ? """, @@ -121,11 +121,11 @@ def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]: return Wallet(**row) if row else None -# wallet transactions -# ------------------- +# wallet payments +# --------------- -def get_wallet_transaction(wallet_id: str, payhash: str) -> Optional[Transaction]: +def get_wallet_payment(wallet_id: str, payhash: str) -> Optional[Payment]: with open_db() as db: row = db.fetchone( """ @@ -136,45 +136,51 @@ def get_wallet_transaction(wallet_id: str, payhash: str) -> Optional[Transaction (wallet_id, payhash), ) - return Transaction(**row) if row else None + return Payment(**row) if row else None -def get_wallet_transactions(wallet_id: str, *, pending: bool = False) -> List[Transaction]: +def get_wallet_payments(wallet_id: str, *, include_all_pending: bool = False) -> List[Payment]: with open_db() as db: + if include_all_pending: + clause = "pending = 1" + else: + clause = "((amount > 0 AND pending = 0) OR amount < 0)" + rows = db.fetchall( - """ + f""" SELECT payhash, amount, fee, pending, memo, time FROM apipayments - WHERE wallet = ? AND pending = ? + WHERE wallet = ? AND {clause} ORDER BY time DESC """, - (wallet_id, int(pending)), + (wallet_id,), ) - return [Transaction(**row) for row in rows] + return [Payment(**row) for row in rows] -# transactions -# ------------ +# payments +# -------- -def create_transaction(*, wallet_id: str, payhash: str, amount: str, memo: str) -> Transaction: +def create_payment(*, wallet_id: str, payhash: str, amount: str, memo: str, fee: int = 0) -> Payment: with open_db() as db: db.execute( """ - INSERT INTO apipayments (wallet, payhash, amount, pending, memo) - VALUES (?, ?, ?, ?, ?) + INSERT INTO apipayments (wallet, payhash, amount, pending, memo, fee) + VALUES (?, ?, ?, ?, ?, ?) """, - (wallet_id, payhash, amount, 1, memo), + (wallet_id, payhash, amount, 1, memo, fee), ) - return get_wallet_transaction(wallet_id, payhash) + return get_wallet_payment(wallet_id, payhash) -def update_transaction_status(payhash: str, pending: bool) -> None: +def update_payment_status(payhash: str, pending: bool) -> None: with open_db() as db: db.execute("UPDATE apipayments SET pending = ? WHERE payhash = ?", (int(pending), payhash,)) -def check_pending_transactions(wallet_id: str) -> None: - pass +def delete_payment(payhash: str) -> None: + with open_db() as db: + db.execute("DELETE FROM apipayments WHERE payhash = ?", (payhash,)) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index e1f112d24..120ecc360 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -1,4 +1,3 @@ -from decimal import Decimal from typing import List, NamedTuple, Optional @@ -24,20 +23,24 @@ class Wallet(NamedTuple): user: str adminkey: str inkey: str - balance: Decimal + balance_msat: int - def get_transaction(self, payhash: str) -> "Transaction": - from .crud import get_wallet_transaction + @property + def balance(self) -> int: + return int(self.balance / 1000) - return get_wallet_transaction(self.id, payhash) + def get_payment(self, payhash: str) -> "Payment": + from .crud import get_wallet_payment - def get_transactions(self) -> List["Transaction"]: - from .crud import get_wallet_transactions + return get_wallet_payment(self.id, payhash) - return get_wallet_transactions(self.id) + def get_payments(self, *, include_all_pending: bool = False) -> List["Payment"]: + from .crud import get_wallet_payments + + return get_wallet_payments(self.id, include_all_pending=include_all_pending) -class Transaction(NamedTuple): +class Payment(NamedTuple): payhash: str pending: bool amount: int @@ -54,10 +57,19 @@ class Transaction(NamedTuple): return self.amount / 1000 @property - def tx_type(self) -> str: - return "payment" if self.amount < 0 else "invoice" + def is_in(self) -> bool: + return self.amount > 0 + + @property + def is_out(self) -> bool: + return self.amount < 0 def set_pending(self, pending: bool) -> None: - from .crud import update_transaction_status + from .crud import update_payment_status - update_transaction_status(self.payhash, pending) + update_payment_status(self.payhash, pending) + + def delete(self) -> None: + from .crud import delete_payment + + delete_payment(self.payhash) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 8cbbdf53d..0bd86bf5f 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -1,29 +1,36 @@ Vue.component(VueQrcode.name, VueQrcode); -function generateChart(canvas, transactions) { +function generateChart(canvas, payments) { var txs = []; var n = 0; var data = { labels: [], - sats: [], + income: [], + outcome: [], cumulative: [] }; - _.each(transactions.sort(function (a, b) { + _.each(payments.slice(0).sort(function (a, b) { return a.time - b.time; }), function (tx) { txs.push({ - day: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'), + hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'), sat: tx.sat, }); }); - _.each(_.groupBy(txs, 'day'), function (value, day) { - var sat = _.reduce(value, function(memo, tx) { return memo + tx.sat; }, 0); - n = n + sat; + _.each(_.groupBy(txs, 'hour'), function (value, day) { + var income = _.reduce(value, function(memo, tx) { + return (tx.sat >= 0) ? memo + tx.sat : memo; + }, 0); + var outcome = _.reduce(value, function(memo, tx) { + return (tx.sat < 0) ? memo + Math.abs(tx.sat) : memo; + }, 0); + n = n + income - outcome; data.labels.push(day); - data.sats.push(sat); + data.income.push(income); + data.outcome.push(outcome); data.cumulative.push(n); }); @@ -36,19 +43,25 @@ function generateChart(canvas, transactions) { data: data.cumulative, type: 'line', label: 'balance', - borderColor: '#673ab7', // deep-purple + backgroundColor: '#673ab7', // deep-purple + borderColor: '#673ab7', borderWidth: 4, pointRadius: 3, fill: false }, { - data: data.sats, + data: data.income, type: 'bar', - label: 'tx', - backgroundColor: function (ctx) { - var value = ctx.dataset.data[ctx.dataIndex]; - return (value < 0) ? '#e91e63' : '#4caf50'; // pink : green - } + label: 'in', + barPercentage: 0.75, + backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green + }, + { + data: data.outcome, + type: 'bar', + label: 'out', + barPercentage: 0.75, + backgroundColor: window.Color('rgb(233,30,99)').alpha(0.5).rgbString() // pink } ] }, @@ -64,12 +77,22 @@ function generateChart(canvas, transactions) { xAxes: [{ type: 'time', display: true, + offset: true, time: { minUnit: 'hour', stepSize: 3 } }], }, + // performance tweaks + animation: { + duration: 0 + }, + elements: { + line: { + tension: 0 + } + } } }); } @@ -80,7 +103,6 @@ new Vue({ mixins: [windowMixin], data: function () { return { - txUpdate: null, receive: { show: false, status: 'pending', @@ -97,7 +119,8 @@ new Vue({ bolt11: '' } }, - transactionsTable: { + payments: [], + paymentsTable: { columns: [ {name: 'memo', align: 'left', label: 'Memo', field: 'memo'}, {name: 'date', align: 'left', label: 'Date', field: 'date', sortable: true}, @@ -107,24 +130,38 @@ new Vue({ rowsPerPage: 10 } }, - transactionsChart: { + paymentsChart: { show: false } }; }, computed: { + balance: function () { + if (this.payments.length) { + return _.pluck(this.payments, 'amount').reduce(function (a, b) { return a + b; }, 0) / 1000; + } + return this.w.wallet.sat; + }, + fbalance: function () { + return LNbits.utils.formatSat(this.balance) + }, canPay: function () { if (!this.send.invoice) return false; - return this.send.invoice.sat < this.w.wallet.balance; + return this.send.invoice.sat < this.balance; }, - transactions: function () { - var data = (this.txUpdate) ? this.txUpdate : this.w.transactions; - return data.sort(function (a, b) { - return b.time - a.time; - }); + pendingPaymentsExist: function () { + return (this.payments) + ? _.where(this.payments, {pending: 1}).length > 0 + : false; } }, methods: { + showChart: function () { + this.paymentsChart.show = true; + this.$nextTick(function () { + generateChart(this.$refs.canvas, this.payments); + }); + }, showReceiveDialog: function () { this.receive = { show: true, @@ -133,7 +170,8 @@ new Vue({ data: { amount: null, memo: '' - } + }, + paymentChecker: null }; }, showSendDialog: function () { @@ -145,11 +183,11 @@ new Vue({ } }; }, - showChart: function () { - this.transactionsChart.show = true; - this.$nextTick(function () { - generateChart(this.$refs.canvas, this.transactions); - }); + closeReceiveDialog: function () { + var checker = this.receive.paymentChecker; + setTimeout(function () { + clearInterval(checker); + }, 10000); }, createInvoice: function () { var self = this; @@ -159,15 +197,15 @@ new Vue({ self.receive.status = 'success'; self.receive.paymentReq = response.data.payment_request; - var check_invoice = setInterval(function () { - LNbits.api.getInvoice(self.w.wallet, response.data.payment_hash).then(function (response) { + self.receive.paymentChecker = setInterval(function () { + LNbits.api.getPayment(self.w.wallet, response.data.payment_hash).then(function (response) { if (response.data.paid) { - self.refreshTransactions(); + self.fetchPayments(); self.receive.show = false; - clearInterval(check_invoice); + clearInterval(self.receive.paymentChecker); } }); - }, 3000); + }, 2000); }).catch(function (error) { LNbits.utils.notifyApiError(error); @@ -177,8 +215,14 @@ new Vue({ decodeInvoice: function () { try { var invoice = decode(this.send.data.bolt11); - } catch (err) { - this.$q.notify({type: 'warning', message: err}); + } catch (error) { + this.$q.notify({ + timeout: 3000, + type: 'warning', + message: error + '.', + caption: '400 BAD REQUEST', + icon: null + }); return; } @@ -203,19 +247,57 @@ new Vue({ this.send.invoice = Object.freeze(cleanInvoice); }, payInvoice: function () { - alert('pay!'); + var self = this; + + dismissPaymentMsg = this.$q.notify({ + timeout: 0, + message: 'Processing payment...', + icon: null + }); + + LNbits.api.payInvoice(this.w.wallet, this.send.data.bolt11).catch(function (error) { + LNbits.utils.notifyApiError(error); + }); + + paymentChecker = setInterval(function () { + LNbits.api.getPayment(self.w.wallet, self.send.invoice.hash).then(function (response) { + if (response.data.paid) { + this.send.show = false; + clearInterval(paymentChecker); + dismissPaymentMsg(); + self.fetchPayments(); + } + }); + }, 2000); }, deleteWallet: function (walletId, user) { LNbits.href.deleteWallet(walletId, user); }, - refreshTransactions: function (notify) { + fetchPayments: function (checkPending) { var self = this; - LNbits.api.getTransactions(this.w.wallet).then(function (response) { - self.txUpdate = response.data.map(function (obj) { - return LNbits.map.transaction(obj); + return LNbits.api.getPayments(this.w.wallet, checkPending).then(function (response) { + self.payments = response.data.map(function (obj) { + return LNbits.map.payment(obj); + }).sort(function (a, b) { + return b.time - a.time; }); }); + }, + checkPendingPayments: function () { + var dismissMsg = this.$q.notify({ + timeout: 0, + message: 'Checking pending transactions...', + icon: null + }); + + this.fetchPayments(true).then(function () { + dismissMsg(); + }); } + }, + created: function () { + this.fetchPayments(); + this.checkPendingPayments(); } }); diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 0565279f7..f729e3e85 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -4,9 +4,9 @@ {% block scripts %} - {{ window_vars(user, wallet) }} {% assets filters='rjsmin', output='__bundle__/core/chart.js', + 'vendor/moment@2.24.0/moment.min.js', 'vendor/chart.js@2.9.3/chart.min.js' %} {% endassets %} @@ -24,7 +24,7 @@
-

{% raw %}{{ w.wallet.fsat }}{% endraw %} sat

+

{% raw %}{{ fbalance }}{% endraw %} sat

@@ -50,16 +50,19 @@
Export to CSV + Show chart
+ :columns="paymentsTable.columns" + :pagination.sync="paymentsTable.pagination"> {% raw %}