refactor: "payments" is the name, and API updates

This commit is contained in:
Eneko Illarramendi 2020-03-07 22:27:00 +01:00
parent 6e26e06aea
commit d862b16ee6
16 changed files with 355 additions and 280 deletions

View File

@ -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()

View File

@ -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,))

View File

@ -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)

View File

@ -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();
}
});

View File

@ -4,9 +4,9 @@
{% block scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.13.0/moment.min.js"></script>
{{ 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' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
@ -24,7 +24,7 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<h4 class="q-my-none"><strong>{% raw %}{{ w.wallet.fsat }}{% endraw %}</strong> sat</h4>
<h3 class="q-my-none"><strong>{% raw %}{{ fbalance }}{% endraw %}</strong> sat</h3>
</q-card-section>
<div class="row q-pb-md q-px-md q-col-gutter-md">
<div class="col">
@ -50,16 +50,19 @@
</div>
<div class="col-auto">
<q-btn flat color="grey" onclick="exportbut()">Export to CSV</q-btn>
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
<q-tooltip>Check pending</q-tooltip>
</q-btn>-->
<q-btn dense flat round icon="show_chart" color="grey" @click="showChart">
<q-tooltip>Show chart</q-tooltip>
</q-btn>
</div>
</div>
<q-table dense flat
:data="transactions"
:data="payments"
row-key="payhash"
:columns="transactionsTable.columns"
:pagination.sync="transactionsTable.pagination">
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
@ -71,11 +74,14 @@
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-tr :props="props" v-if="props.row.isPaid">
<q-td auto-width class="lnbits__q-table__icon-td">
<q-icon size="14px"
<q-icon v-if="props.row.isPaid" size="14px"
:name="(props.row.sat < 0) ? 'call_made' : 'call_received'"
:color="(props.row.sat < 0) ? 'pink' : 'green'"></q-icon>
<q-icon v-else name="settings_ethernet" color="grey">
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
<q-td key="memo" :props="props">
{{ props.row.memo }}
@ -169,8 +175,8 @@
</div>
</div>
<q-dialog v-model="receive.show" position="top">
<q-card class="q-pa-md" style="width: 500px">
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card class="q-pa-lg" style="width: 500px">
<q-form v-if="!receive.paymentReq" class="q-gutter-md">
<q-input filled dense
v-model.number="receive.data.amount"
@ -208,7 +214,7 @@
</q-dialog>
<q-dialog v-model="send.show" position="top">
<q-card class="q-pa-md" style="width: 500px">
<q-card class="q-pa-lg" style="width: 500px">
<q-form v-if="!send.invoice" class="q-gutter-md">
<q-input filled dense
v-model="send.data.bolt11"
@ -232,6 +238,7 @@
<div v-else>
{% raw %}
<h6 class="q-my-none">{{ send.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator>
<p style="word-break: break-all">
<strong>Memo:</strong> {{ send.invoice.description }}<br>
<strong>Expire date:</strong> {{ send.invoice.expireDate }}<br>
@ -252,8 +259,8 @@
</q-card>
</q-dialog>
<q-dialog v-model="transactionsChart.show" position="top">
<q-card class="q-pa-md" style="width: 800px; max-width: unset">
<q-dialog v-model="paymentsChart.show" position="top">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>

View File

@ -79,13 +79,15 @@ def wallet():
@check_user_exists()
def deletewallet():
wallet_id = request.args.get("wal", type=str)
user_wallet_ids = g.user.wallet_ids
if wallet_id not in g.user.wallet_ids:
if wallet_id not in user_wallet_ids:
abort(Status.FORBIDDEN, "Not your wallet.")
else:
delete_wallet(user_id=g.user.id, wallet_id=wallet_id)
user_wallet_ids.remove(wallet_id)
if g.user.wallets:
return redirect(url_for("core.wallet", usr=g.user.id, wal=g.user.wallets[0].id))
if user_wallet_ids:
return redirect(url_for("core.wallet", usr=g.user.id, wal=user_wallet_ids[0]))
return redirect(url_for("core.home"))

View File

@ -1,17 +1,30 @@
from flask import g, jsonify
from flask import g, jsonify, request
from lnbits import bolt11
from lnbits.core import core_app
from lnbits.decorators import api_check_wallet_macaroon, api_validate_post_request
from lnbits.helpers import Status
from lnbits.settings import WALLET
from lnbits.settings import FEE_RESERVE, WALLET
from .crud import create_transaction
from .crud import create_payment
@core_app.route("/api/v1/invoices", methods=["POST"])
@api_validate_post_request(required_params=["amount", "memo"])
@core_app.route("/api/v1/payments", methods=["GET"])
@api_check_wallet_macaroon(key_type="invoice")
def api_invoices():
def api_payments():
if "check_pending" in request.args:
for payment in g.wallet.get_payments(include_all_pending=True):
if payment.is_out:
payment.set_pending(WALLET.get_payment_status(payment.payhash).pending)
elif payment.is_in:
payment.set_pending(WALLET.get_invoice_status(payment.payhash).pending)
return jsonify(g.wallet.get_payments()), Status.OK
@api_check_wallet_macaroon(key_type="invoice")
@api_validate_post_request(required_params=["amount", "memo"])
def api_payments_create_invoice():
if not isinstance(g.data["amount"], int) or g.data["amount"] < 1:
return jsonify({"message": "`amount` needs to be a positive integer."}), Status.BAD_REQUEST
@ -25,38 +38,77 @@ def api_invoices():
server_error = True
if server_error:
return jsonify({"message": "Unexpected backend error. Try again later."}), 500
return jsonify({"message": "Unexpected backend error. Try again later."}), Status.INTERNAL_SERVER_ERROR
amount_msat = g.data["amount"] * 1000
create_transaction(wallet_id=g.wallet.id, payhash=payhash, amount=amount_msat, memo=g.data["memo"])
create_payment(wallet_id=g.wallet.id, payhash=payhash, amount=amount_msat, memo=g.data["memo"])
return jsonify({"payment_request": payment_request, "payment_hash": payhash}), Status.CREATED
@core_app.route("/api/v1/invoices/<payhash>", defaults={"incoming": True}, methods=["GET"])
@core_app.route("/api/v1/payments/<payhash>", defaults={"incoming": False}, methods=["GET"])
@api_check_wallet_macaroon(key_type="invoice")
def api_transaction(payhash, incoming):
tx = g.wallet.get_transaction(payhash)
@api_validate_post_request(required_params=["bolt11"])
def api_payments_pay_invoice():
if not isinstance(g.data["bolt11"], str) or not g.data["bolt11"].strip():
return jsonify({"message": "`bolt11` needs to be a valid string."}), Status.BAD_REQUEST
if not tx:
return jsonify({"message": "Transaction does not exist."}), Status.NOT_FOUND
elif not tx.pending:
try:
invoice = bolt11.decode(g.data["bolt11"])
if invoice.amount_msat == 0:
return jsonify({"message": "Amountless invoices not supported."}), Status.BAD_REQUEST
if invoice.amount_msat > g.wallet.balance_msat:
return jsonify({"message": "Insufficient balance."}), Status.FORBIDDEN
create_payment(
wallet_id=g.wallet.id,
payhash=invoice.payment_hash,
amount=-invoice.amount_msat,
memo=invoice.description,
fee=-invoice.amount_msat * FEE_RESERVE,
)
r, server_error, fee_msat, error_message = WALLET.pay_invoice(g.data["bolt11"])
except Exception as e:
server_error = True
error_message = str(e)
if server_error:
return jsonify({"message": error_message}), Status.INTERNAL_SERVER_ERROR
return jsonify({"payment_hash": invoice.payment_hash}), Status.CREATED
@core_app.route("/api/v1/payments", methods=["POST"])
@api_validate_post_request(required_params=["out"])
def api_payments_create():
if g.data["out"] is True:
return api_payments_pay_invoice()
return api_payments_create_invoice()
@core_app.route("/api/v1/payments/<payhash>", methods=["GET"])
@api_check_wallet_macaroon(key_type="invoice")
def api_payment(payhash):
payment = g.wallet.get_payment(payhash)
if not payment:
return jsonify({"message": "Payment does not exist."}), Status.NOT_FOUND
elif not payment.pending:
return jsonify({"paid": True}), Status.OK
try:
is_settled = WALLET.get_invoice_status(payhash).settled
if payment.is_out:
is_paid = WALLET.get_payment_status(payhash).paid
elif payment.is_in:
is_paid = WALLET.get_invoice_status(payhash).paid
except Exception:
return jsonify({"paid": False}), Status.OK
if is_settled is True:
tx.set_pending(False)
if is_paid is True:
payment.set_pending(False)
return jsonify({"paid": True}), Status.OK
return jsonify({"paid": False}), Status.OK
@core_app.route("/api/v1/transactions", methods=["GET"])
@api_check_wallet_macaroon(key_type="invoice")
def api_transactions():
return jsonify(g.wallet.get_transactions()), Status.OK

View File

@ -47,8 +47,9 @@ class Status:
PAYMENT_REQUIRED = 402
FORBIDDEN = 403
NOT_FOUND = 404
TOO_MANY_REQUESTS = 429
METHOD_NOT_ALLOWED = 405
TOO_MANY_REQUESTS = 429
INTERNAL_SERVER_ERROR = 500
class MegaEncoder(json.JSONEncoder):

View File

@ -13,22 +13,27 @@ var LNbits = {
});
},
createInvoice: function (wallet, amount, memo) {
return this.request('post', '/api/v1/invoices', wallet.inkey, {
return this.request('post', '/api/v1/payments', wallet.inkey, {
out: false,
amount: amount,
memo: memo
});
},
getInvoice: function (wallet, payhash) {
return this.request('get', '/api/v1/invoices/' + payhash, wallet.inkey);
payInvoice: function (wallet, bolt11) {
return this.request('post', '/api/v1/payments', wallet.inkey, {
out: true,
bolt11: bolt11
});
},
getTransactions: function (wallet) {
return this.request('get', '/api/v1/transactions', wallet.inkey);
getPayments: function (wallet, checkPending) {
var query_param = (checkPending) ? '?check_pending' : '';
return this.request('get', ['/api/v1/payments', query_param].join(''), wallet.inkey);
},
getPayment: function (wallet, payhash) {
return this.request('get', '/api/v1/payments/' + payhash, wallet.inkey);
}
},
href: {
openWallet: function (wallet) {
window.location.href = '/wallet?usr=' + wallet.user + '&wal=' + wallet.id;
},
createWallet: function (walletName, userId) {
window.location.href = '/wallet?' + (userId ? 'usr=' + userId + '&' : '') + 'nme=' + walletName;
},
@ -42,14 +47,6 @@ var LNbits = {
obj.url = ['/', obj.code, '/'].join('');
return obj;
},
transaction: function (data) {
var obj = _.object(['payhash', 'pending', 'amount', 'fee', 'memo', 'time'], data);
obj.date = Quasar.utils.date.formatDate(new Date(obj.time * 1000), 'YYYY-MM-DD HH:mm')
obj.msat = obj.amount;
obj.sat = obj.msat / 1000;
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.sat);
return obj;
},
user: function (data) {
var obj = _.object(['id', 'email', 'extensions', 'wallets'], data);
var mapWallet = this.wallet;
@ -62,10 +59,22 @@ var LNbits = {
},
wallet: function (data) {
var obj = _.object(['id', 'name', 'user', 'adminkey', 'inkey', 'balance'], data);
obj.sat = Math.round(obj.balance);
obj.msat = obj.balance;
obj.sat = Math.round(obj.balance / 1000);
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.sat);
obj.url = ['/wallet?usr=', obj.user, '&wal=', obj.id].join('');
return obj;
},
payment: function (data) {
var obj = _.object(['payhash', 'pending', 'amount', 'fee', 'memo', 'time'], data);
obj.date = Quasar.utils.date.formatDate(new Date(obj.time * 1000), 'YYYY-MM-DD HH:mm')
obj.msat = obj.amount;
obj.sat = obj.msat / 1000;
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.sat);
obj.isIn = obj.amount > 0;
obj.isOut = obj.amount < 0;
obj.isPaid = obj.pending == 0;
return obj;
}
},
utils: {
@ -79,11 +88,10 @@ var LNbits = {
500: 'negative'
}
Quasar.plugins.Notify.create({
progress: true,
timeout: 3000,
type: types[error.response.status] || 'warning',
message: error.response.data.message || null,
caption: [error.response.status, ' ', error.response.statusText].join('') || null,
caption: [error.response.status, ' ', error.response.statusText].join('').toUpperCase() || null,
icon: null
});
}
@ -98,7 +106,7 @@ var windowMixin = {
extensions: [],
user: null,
wallet: null,
transactions: [],
payments: [],
}
};
},
@ -122,11 +130,6 @@ var windowMixin = {
if (window.wallet) {
this.w.wallet = Object.freeze(LNbits.map.wallet(window.wallet));
}
if (window.transactions) {
this.w.transactions = window.transactions.map(function (data) {
return LNbits.map.transaction(data);
});
}
if (window.extensions) {
var user = this.w.user;
this.w.extensions = Object.freeze(window.extensions.map(function (data) {

File diff suppressed because one or more lines are too long

View File

@ -6,7 +6,6 @@
{% endif %}
{% if wallet %}
window.wallet = {{ wallet | tojson | safe }};
window.transactions = {{ wallet.get_transactions() | tojson | safe }};
{% endif %}
</script>
{%- endmacro %}

View File

@ -13,11 +13,16 @@ class PaymentResponse(NamedTuple):
raw_response: Response
failed: bool = False
fee_msat: int = 0
error_message: Optional[str] = None
class TxStatus(NamedTuple):
class PaymentStatus(NamedTuple):
raw_response: Response
settled: Optional[bool] = None
paid: Optional[bool] = None
@property
def pending(self) -> bool:
return self.paid is not True
class Wallet(ABC):
@ -26,13 +31,13 @@ class Wallet(ABC):
pass
@abstractmethod
def pay_invoice(self, bolt11: str) -> Response:
def pay_invoice(self, bolt11: str) -> PaymentResponse:
pass
@abstractmethod
def get_invoice_status(self, payment_hash: str) -> TxStatus:
def get_invoice_status(self, payment_hash: str) -> PaymentStatus:
pass
@abstractmethod
def get_payment_status(self, payment_hash: str) -> TxStatus:
def get_payment_status(self, payment_hash: str) -> PaymentStatus:
pass

View File

@ -1,5 +1,5 @@
from requests import get, post
from .base import InvoiceResponse, PaymentResponse, TxStatus, Wallet
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
class LndWallet(Wallet):
@ -41,15 +41,15 @@ class LndWallet(Wallet):
)
return PaymentResponse(r, not r.ok)
def get_invoice_status(self, payment_hash: str) -> TxStatus:
def get_invoice_status(self, payment_hash: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/v1/invoice/{payment_hash}", headers=self.auth_read, verify=False)
if not r.ok or "settled" not in r.json():
return TxStatus(r, None)
return PaymentStatus(r, None)
return TxStatus(r, r.json()["settled"])
return PaymentStatus(r, r.json()["settled"])
def get_payment_status(self, payment_hash: str) -> TxStatus:
def get_payment_status(self, payment_hash: str) -> PaymentStatus:
r = get(
url=f"{self.endpoint}/v1/payments",
headers=self.auth_admin,
@ -58,11 +58,11 @@ class LndWallet(Wallet):
)
if not r.ok:
return TxStatus(r, None)
return PaymentStatus(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
statuses = {"UNKNOWN": None, "IN_FLIGHT": None, "SUCCEEDED": True, "FAILED": False}
return TxStatus(r, statuses[payment["status"]] if payment else None)
return PaymentStatus(r, statuses[payment["status"]] if payment else None)

View File

@ -1,6 +1,6 @@
from requests import get, post
from .base import InvoiceResponse, PaymentResponse, TxStatus, Wallet
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
class LNPayWallet(Wallet):
@ -37,20 +37,14 @@ class LNPayWallet(Wallet):
return PaymentResponse(r, not r.ok)
def get_invoice_status(self, payment_hash: str) -> TxStatus:
def get_invoice_status(self, payment_hash: str) -> PaymentStatus:
return self.get_payment_status(payment_hash)
def get_payment_status(self, payment_hash: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/user/lntx/{payment_hash}", headers=self.auth_api)
if not r.ok:
return TxStatus(r, None)
return PaymentStatus(r, None)
statuses = {0: None, 1: True, -1: False}
return TxStatus(r, statuses[r.json()["settled"]])
def get_payment_status(self, payment_hash: str) -> TxStatus:
r = get(url=f"{self.endpoint}/user/lntx/{payment_hash}", headers=self.auth_api)
if not r.ok:
return TxStatus(r, None)
statuses = {0: None, 1: True, -1: False}
return TxStatus(r, statuses[r.json()["settled"]])
return PaymentStatus(r, statuses[r.json()["settled"]])

View File

@ -1,6 +1,6 @@
from requests import post
from .base import InvoiceResponse, PaymentResponse, TxStatus, Wallet
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
class LntxbotWallet(Wallet):
@ -23,38 +23,39 @@ class LntxbotWallet(Wallet):
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post(url=f"{self.endpoint}/payinvoice", headers=self.auth_admin, json={"invoice": bolt11})
failed, fee_msat = not r.ok, 0
failed, fee_msat, error_message = not r.ok, 0, None
if r.ok:
data = r.json()
if "error" in data and data["error"]:
failed = True
error_message = data["message"]
elif "fee_msat" in data:
fee_msat = data["fee_msat"]
return PaymentResponse(r, failed, fee_msat)
return PaymentResponse(r, failed, fee_msat, error_message)
def get_invoice_status(self, payment_hash: str) -> TxStatus:
def get_invoice_status(self, payment_hash: str) -> PaymentStatus:
r = post(url=f"{self.endpoint}/invoicestatus/{payment_hash}?wait=false", headers=self.auth_invoice)
if not r.ok:
return TxStatus(r, None)
return PaymentStatus(r, None)
data = r.json()
if "error" in data:
return TxStatus(r, None)
return PaymentStatus(r, None)
if "preimage" not in data or not data["preimage"]:
return TxStatus(r, False)
return PaymentStatus(r, False)
return TxStatus(r, True)
return PaymentStatus(r, True)
def get_payment_status(self, payment_hash: str) -> TxStatus:
def get_payment_status(self, payment_hash: str) -> PaymentStatus:
r = post(url=f"{self.endpoint}/paymentstatus/{payment_hash}", headers=self.auth_invoice)
if not r.ok or "error" in r.json():
return TxStatus(r, None)
return PaymentStatus(r, None)
statuses = {"complete": True, "failed": False, "unknown": None}
return TxStatus(r, statuses[r.json().get("status", "unknown")])
statuses = {"complete": True, "failed": False, "pending": None, "unknown": None}
return PaymentStatus(r, statuses[r.json().get("status", "unknown")])

View File

@ -1,6 +1,6 @@
from requests import get, post
from .base import InvoiceResponse, PaymentResponse, TxStatus, Wallet
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
class OpenNodeWallet(Wallet):
@ -28,20 +28,20 @@ class OpenNodeWallet(Wallet):
r = post(url=f"{self.endpoint}/v2/withdrawals", headers=self.auth_admin, json={"type": "ln", "address": bolt11})
return PaymentResponse(r, not r.ok)
def get_invoice_status(self, payment_hash: str) -> TxStatus:
def get_invoice_status(self, payment_hash: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/v1/charge/{payment_hash}", headers=self.auth_invoice)
if not r.ok:
return TxStatus(r, None)
return PaymentStatus(r, None)
statuses = {"processing": None, "paid": True, "unpaid": False}
return TxStatus(r, statuses[r.json()["data"]["status"]])
return PaymentStatus(r, statuses[r.json()["data"]["status"]])
def get_payment_status(self, payment_hash: str) -> TxStatus:
def get_payment_status(self, payment_hash: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/v1/withdrawal/{payment_hash}", headers=self.auth_admin)
if not r.ok:
return TxStatus(r, None)
return PaymentStatus(r, None)
statuses = {"pending": None, "confirmed": True, "error": False, "failed": False}
return TxStatus(r, statuses[r.json()["data"]["status"]])
return PaymentStatus(r, statuses[r.json()["data"]["status"]])