From 3cd15c40fc7d4755ff12df5cfdb2ace7c1061602 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 11 Oct 2020 22:19:27 -0300 Subject: [PATCH] lnurl-pay and lnurl-withdraw UI. --- lnbits/core/static/js/wallet.js | 258 +++++++++++++++---------- lnbits/core/templates/core/wallet.html | 145 ++++++++------ lnbits/core/views/api.py | 29 +-- 3 files changed, 259 insertions(+), 173 deletions(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 3a5eec4..b39a25d 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -14,12 +14,8 @@ function generateChart(canvas, payments) { } _.each( - payments - .filter(p => !p.pending) - .sort(function (a, b) { - return a.time - b.time - }), - function (tx) { + payments.filter(p => !p.pending).sort((a, b) => a.time - b.time), + tx => { txs.push({ hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'), sat: tx.sat @@ -27,19 +23,15 @@ function generateChart(canvas, payments) { } ) - _.each(_.groupBy(txs, 'hour'), function (value, day) { + _.each(_.groupBy(txs, 'hour'), (value, day) => { var income = _.reduce( value, - function (memo, tx) { - return tx.sat >= 0 ? memo + tx.sat : memo - }, + (memo, tx) => (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 - }, + (memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo), 0 ) n = n + income - outcome @@ -124,23 +116,27 @@ new Vue({ show: false, status: 'pending', paymentReq: null, + minMax: [0, 2100000000000000], + lnurl: null, data: { amount: null, memo: '' } }, - send: { + parse: { show: false, invoice: null, - lnurl: {}, + lnurlpay: null, data: { - request: '' + request: '', + amount: 0 + }, + paymentChecker: null, + camera: { + show: false, + camera: 'auto' } }, - theCamera: { - show: false, - camera: 'auto' - }, payments: [], paymentsTable: { columns: [ @@ -197,8 +193,8 @@ new Vue({ return LNbits.utils.search(this.payments, q) }, canPay: function () { - if (!this.send.invoice) return false - return this.send.invoice.sat <= this.balance + if (!this.parse.invoice) return false + return this.parse.invoice.sat <= this.balance }, pendingPaymentsExist: function () { return this.payments @@ -206,56 +202,55 @@ new Vue({ : false } }, + filters: { + msatoshiFormat: function (value) { + return LNbits.utils.formatSat(value / 1000) + } + }, methods: { closeCamera: function () { - this.theCamera.show = false + this.parse.camera.show = false }, showCamera: function () { - this.theCamera.show = true + this.parse.camera.show = true }, showChart: function () { this.paymentsChart.show = true - this.$nextTick(function () { + this.$nextTick(() => { generateChart(this.$refs.canvas, this.payments) }) }, showReceiveDialog: function () { - this.receive = { - show: true, - status: 'pending', - paymentReq: null, - data: { - amount: null, - memo: '' - }, - paymentChecker: null - } + this.receive.show = true + this.receive.status = 'pending' + this.receive.paymentReq = null + this.receive.data.amount = null + this.receive.data.memo = null + this.receive.paymentChecker = null + this.receive.minMax = [0, 2100000000000000] + this.receive.lnurl = null }, - showSendDialog: function () { - this.send = { - show: true, - invoice: null, - lnurl: {}, - data: { - request: '' - }, - paymentChecker: null - } + showParseDialog: function () { + this.parse.show = true + this.parse.invoice = null + this.parse.lnurlpay = null + this.parse.data.request = '' + this.parse.data.paymentChecker = null + this.parse.camera.show = false }, closeReceiveDialog: function () { var checker = this.receive.paymentChecker - setTimeout(function () { + setTimeout(() => { clearInterval(checker) }, 10000) }, - closeSendDialog: function () { - var checker = this.send.paymentChecker - setTimeout(function () { + closeParseDialog: function () { + var checker = this.parse.paymentChecker + setTimeout(() => { clearInterval(checker) }, 1000) }, createInvoice: function () { - var self = this this.receive.status = 'loading' LNbits.api .createInvoice( @@ -263,59 +258,96 @@ new Vue({ this.receive.data.amount, this.receive.data.memo ) - .then(function (response) { - self.receive.status = 'success' - self.receive.paymentReq = response.data.payment_request + .then(response => { + this.receive.status = 'success' + this.receive.paymentReq = response.data.payment_request + + if (this.receive.lnurl) { + // send invoice to lnurl callback + console.log('sending', this.receive.lnurl) + LNbits.api.sendInvoiceToLnurlWithdraw(this.receive.paymentReq) + } - self.receive.paymentChecker = setInterval(function () { + this.receive.paymentChecker = setInterval(() => { LNbits.api - .getPayment(self.g.wallet, response.data.payment_hash) - .then(function (response) { + .getPayment(this.g.wallet, response.data.payment_hash) + .then(response => { if (response.data.paid) { - self.fetchPayments() - self.receive.show = false - clearInterval(self.receive.paymentChecker) + this.fetchPayments() + this.receive.show = false + clearInterval(this.receive.paymentChecker) } }) }, 2000) }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - self.receive.status = 'pending' + .catch(err => { + LNbits.utils.notifyApiError(err) + this.receive.status = 'pending' }) }, decodeQR: function (res) { - this.send.data.request = res + this.parse.data.request = res this.decodeRequest() - this.sendCamera.show = false + this.parse.camera.show = false }, decodeRequest: function () { - if (this.send.data.request.startsWith('lightning:')) { - this.send.data.request = this.send.data.request.slice(10) + this.parse.show = true + + if (this.parse.data.request.startsWith('lightning:')) { + this.parse.data.request = this.parse.data.request.slice(10) } - if (this.send.data.request.startsWith('lnurl:')) { - this.send.data.request = this.send.data.request.slice(6) + if (this.parse.data.request.startsWith('lnurl:')) { + this.parse.data.request = this.parse.data.request.slice(6) } - if (this.send.data.request.toLowerCase().startsWith('lnurl1')) { + if (this.parse.data.request.toLowerCase().startsWith('lnurl1')) { LNbits.api .request( 'GET', - '/api/v1/lnurlscan/' + this.send.data.request, + '/api/v1/lnurlscan/' + this.parse.data.request, this.g.user.wallets[0].adminkey ) - .then(function (response) { - this.send.lnurl[response.kind] = Object.freeze(response) + .catch(err => { + LNbits.utils.notifyApiError(err) }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) + .then(response => { + let data = response.data + + if (data.status === 'ERROR') { + Quasar.plugins.Notify.create({ + timeout: 5000, + type: 'warning', + message: data.reason, + caption: `${data.domain} returned an error to the lnurl call.`, + icon: null + }) + return + } + + if (data.kind === 'pay') { + this.parse.lnurlpay = Object.freeze(data) + this.parse.data.amount = data.minSendable / 1000 + } else if (data.kind === 'withdraw') { + this.parse.show = false + this.receive.show = true + this.receive.status = 'pending' + this.receive.data.amount = data.maxWithdrawable + this.receive.data.memo = data.defaultDescription + this.receive.minMax = [data.minWithdrawable, data.maxWithdrawable] + this.receive.lnurl = { + domain: data.domain, + callback: data.callback, + k1: data.k1, + fixed: data.fixed + } + } }) return } let invoice try { - invoice = decode(this.send.data.bolt11) + invoice = decode(this.parse.data.bolt11) } catch (error) { this.$q.notify({ timeout: 3000, @@ -324,6 +356,7 @@ new Vue({ caption: '400 BAD REQUEST', icon: null }) + this.parse.show = false return } @@ -333,7 +366,7 @@ new Vue({ fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000) } - _.each(invoice.data.tags, function (tag) { + _.each(invoice.data.tags, tag => { if (_.isObject(tag) && _.has(tag, 'description')) { if (tag.description === 'payment_hash') { cleanInvoice.hash = tag.value @@ -352,11 +385,37 @@ new Vue({ } }) - this.send.invoice = Object.freeze(cleanInvoice) + this.parse.invoice = Object.freeze(cleanInvoice) }, payInvoice: function () { - var self = this + let dismissPaymentMsg = this.$q.notify({ + timeout: 0, + message: 'Processing payment...', + icon: null + }) + LNbits.api + .payInvoice(this.g.wallet, this.parse.data.bolt11) + .then(response => { + this.parse.paymentChecker = setInterval(() => { + LNbits.api + .getPayment(this.g.wallet, response.data.payment_hash) + .then(res => { + if (res.data.paid) { + this.parse.show = false + clearInterval(this.parse.paymentChecker) + dismissPaymentMsg() + this.fetchPayments() + } + }) + }, 2000) + }) + .catch(err => { + dismissPaymentMsg() + LNbits.utils.notifyApiError(err) + }) + }, + payLnurl: function () { let dismissPaymentMsg = this.$q.notify({ timeout: 0, message: 'Processing payment...', @@ -364,55 +423,52 @@ new Vue({ }) LNbits.api - .payInvoice(this.g.wallet, this.send.data.bolt11) - .then(function (response) { - self.send.paymentChecker = setInterval(function () { + .payInvoice(this.g.wallet, this.parse.data.bolt11) + .then(response => { + this.parse.paymentChecker = setInterval(() => { LNbits.api - .getPayment(self.g.wallet, response.data.payment_hash) - .then(function (res) { + .getPayment(this.g.wallet, response.data.payment_hash) + .then(res => { if (res.data.paid) { - self.send.show = false - clearInterval(self.send.paymentChecker) + this.parse.show = false + clearInterval(this.parse.paymentChecker) dismissPaymentMsg() - self.fetchPayments() + this.fetchPayments() } }) }, 2000) }) - .catch(function (error) { + .catch(err => { dismissPaymentMsg() - LNbits.utils.notifyApiError(error) + LNbits.utils.notifyApiError(err) }) }, deleteWallet: function (walletId, user) { LNbits.utils .confirmDialog('Are you sure you want to delete this wallet?') - .onOk(function () { + .onOk(() => { LNbits.href.deleteWallet(walletId, user) }) }, fetchPayments: function (checkPending) { - var self = this - return LNbits.api .getPayments(this.g.wallet, checkPending) - .then(function (response) { - self.payments = response.data - .map(function (obj) { + .then(response => { + this.payments = response.data + .map(obj => { return LNbits.map.payment(obj) }) - .sort(function (a, b) { + .sort((a, b) => { return b.time - a.time }) }) }, fetchBalance: function () { - var self = this - LNbits.api.getWallet(self.g.wallet).then(function (response) { - self.balance = Math.round(response.data.balance / 1000) + LNbits.api.getWallet(this.g.wallet).then(response => { + this.balance = Math.round(response.data.balance / 1000) EventHub.$emit('update-wallet-balance', [ - self.g.wallet.id, - self.balance + this.g.wallet.id, + this.balance ]) }) }, @@ -423,7 +479,7 @@ new Vue({ icon: null }) - this.fetchPayments(true).then(function () { + this.fetchPayments(true).then(() => { dismissMsg() }) }, diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index bf94e96..405f970 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -16,7 +16,7 @@ unelevated color="deep-purple" class="full-width" - @click="showSendDialog" + @click="showParseDialog" >Paste Request @@ -249,18 +249,26 @@ - + + {% raw %} +

+ {{receive.lnurl.domain}} is requesting an invoice: +

+ Create invoice + + Withdraw from {{receive.lnurl.domain}} + + Create invoice + Cancel Close
+ {% endraw %}
- + -
+
+ {% raw %} +
{{ parse.invoice.fsat }} sat
+ +

+ Description: {{ parse.invoice.description }}
+ Expire date: {{ parse.invoice.expireDate }}
+ Hash: {{ parse.invoice.hash }} +

+ {% endraw %} +
+ Pay + Cancel +
+
+ Not enough funds! + Cancel +
+
+
+ {% raw %} + +

+ {{ parse.lnurlpay.maxSendable | msatoshiFormat }} +

+

+ {{ parse.lnurlpay.domain }} is requesting
+ between {{ parse.lnurlpay.minSendable | msatoshiFormat }} and + {{ parse.lnurlpay.maxSendable | msatoshiFormat }} sat +

+ +

{{ parse.lnurlpay.description }}

+

+ +

+ +
+ Send satoshis + Cancel +
+
+ {% endraw %} +
+
@@ -327,7 +398,7 @@ Read @@ -344,62 +415,16 @@ >
- Cancel + + Cancel +
-
- {% raw %} -
{{ send.invoice.fsat }} sat
- -

- Description: {{ send.invoice.description }}
- Payment hash: {{ send.invoice.hash }}
- Expire date: {{ send.invoice.expireDate }} -

- {% endraw %} -
- Send satoshis - Cancel -
-
- Not enough funds! - Cancel -
-
-
- {% raw %} -
{{ send.invoice.fsat }} sat
- -

- Description: {{ send.invoice.description }}
- Expire date: {{ send.invoice.expireDate }}
- Hash: {{ send.invoice.hash }} -

- {% endraw %} -
- Send satoshis - Cancel -
-
- Not enough funds! - Cancel -
-
- +
@@ -412,7 +437,7 @@ - + diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index b46f40e..5be279e 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -1,15 +1,13 @@ -<<<<<<< HEAD import trio # type: ignore import json +import lnurl +import httpx import traceback from quart import g, jsonify, request, make_response -======= -import lnurl -from quart import g, jsonify, request ->>>>>>> da8fd9a... send/create buttons wip. from http import HTTPStatus from binascii import unhexlify from urllib.parse import urlparse +from typing import Dict from lnbits import bolt11 from lnbits.decorators import api_check_wallet_key, api_validate_post_request @@ -137,7 +135,6 @@ async def api_payment(payment_hash): return jsonify({"paid": not payment.pending}), HTTPStatus.OK -<<<<<<< HEAD @core_app.route("/api/v1/payments/sse", methods=["GET"]) @api_check_wallet_key("invoice") async def api_payments_sse(): @@ -190,8 +187,6 @@ async def api_payments_sse(): ) response.timeout = None return response -======= - return jsonify({"paid": False}), HTTPStatus.OK @core_app.route("/api/v1/lnurlscan/", methods=["GET"]) @@ -206,20 +201,30 @@ async def api_lnurlscan(code: str): if url.is_login: return jsonify({"domain": domain, "kind": "auth", "error": "unsupported"}) - data: lnurl.LnurlResponseModel = lnurl.get(url.url) - if not data.ok: + r = httpx.get(url.url) + if r.is_error: return jsonify({"domain": domain, "error": "failed to get parameters"}) + try: + jdata = json.loads(r.text) + data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata) + except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException): + return jsonify({"domain": domain, "error": f"got invalid response '{r.text[:200]}'"}) + if type(data) is lnurl.LnurlChannelResponse: return jsonify({"domain": domain, "kind": "channel", "error": "unsupported"}) - params = data.dict() + params: Dict = data.dict() if type(data) is lnurl.LnurlWithdrawResponse: params.update(kind="withdraw", fixed=data.min_withdrawable == data.max_withdrawable) if type(data) is lnurl.LnurlPayResponse: params.update(kind="pay", fixed=data.min_sendable == data.max_sendable) + params.update(description=data.metadata.text) + if data.metadata.images: + image = min(data.metadata.images, key=lambda image: len(image[1])) + data_uri = "data:" + image[0] + "," + image[1] + params.update(image=data_uri) params.update(domain=domain) return jsonify(params) ->>>>>>> da8fd9a... send/create buttons wip.