From fa8713de17096743e51631476f5769aca23d64cf Mon Sep 17 00:00:00 2001 From: Arc <33088785+arcbtc@users.noreply.github.com> Date: Sun, 20 Sep 2020 23:50:02 -0300 Subject: [PATCH 01/10] move scan to outside of receive. --- lnbits/core/static/js/wallet.js | 36 +++++++++++++++++++++----- lnbits/core/templates/core/wallet.html | 35 +++++++++++++++++++------ 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 8818196..f3dd8b6 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -136,7 +136,7 @@ new Vue({ bolt11: '' } }, - sendCamera: { + theCamera: { show: false, camera: 'auto' }, @@ -206,11 +206,17 @@ new Vue({ } }, methods: { + // closeCamera: function () { + // this.sendCamera.show = false + // }, + // showCamera: function () { + // this.sendCamera.show = true + // }, closeCamera: function () { - this.sendCamera.show = false + this.theCamera.show = false }, showCamera: function () { - this.sendCamera.show = true + this.theCamera.show = true }, showChart: function () { this.paymentsChart.show = true @@ -247,7 +253,7 @@ new Vue({ }, 10000) }, closeSendDialog: function () { - this.sendCamera.show = false + // this.sendCamera.show = false var checker = this.send.paymentChecker setTimeout(function () { clearInterval(checker) @@ -284,10 +290,26 @@ new Vue({ }) }, decodeQR: function (res) { - this.send.data.bolt11 = res - this.decodeInvoice() - this.sendCamera.show = false + if (res.substring(0, 4) == 'lnurl') { + console.log(res) + var self = this + + LNbits.api + .request('GET', '/lnurlscan/' + res, this.g.user.wallets[0].adminkey) + .then(function (response) { + console.log(response.data) + }) + .catch(function (error) { + clearInterval(self.checker) + LNbits.utils.notifyApiError(error) + }) + } else { + this.send.data.bolt11 = res + this.decodeInvoice() + this.theCamera.show = false + } }, + decodeInvoice: function () { if (this.send.data.bolt11.startsWith('lightning:')) { this.send.data.bolt11 = this.send.data.bolt11.slice(10) diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 5d62046..5bf94da 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -14,7 +14,7 @@
SendReceive
+
+ scan + Use camera to scan an invoice/QR + +
@@ -187,6 +197,7 @@ +
@@ -304,11 +315,6 @@ type="textarea" label="Paste an invoice *" > -
{{ send.invoice.fsat }} sat

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

@@ -363,8 +369,21 @@ + + +
+ +
+
+ Cancel +
+
+
+ - + From 7a5159f29368a5d588bfbc1200c512d5bd9dae8c Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 20 Sep 2020 23:58:02 -0300 Subject: [PATCH 02/10] send/create/scan buttons for clear LNURL support. --- lnbits/core/static/js/wallet.js | 50 ++++--- lnbits/core/templates/core/wallet.html | 134 +++++++++++------- lnbits/core/views/api.py | 40 ++++++ .../lnurlp/templates/lnurlp/display.html | 4 +- 4 files changed, 147 insertions(+), 81 deletions(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index f3dd8b6..3a5eec4 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -1,4 +1,4 @@ -/* globals moment, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart */ +/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart */ Vue.component(VueQrcode.name, VueQrcode) Vue.use(VueQrcodeReader) @@ -132,8 +132,9 @@ new Vue({ send: { show: false, invoice: null, + lnurl: {}, data: { - bolt11: '' + request: '' } }, theCamera: { @@ -206,12 +207,6 @@ new Vue({ } }, methods: { - // closeCamera: function () { - // this.sendCamera.show = false - // }, - // showCamera: function () { - // this.sendCamera.show = true - // }, closeCamera: function () { this.theCamera.show = false }, @@ -240,8 +235,9 @@ new Vue({ this.send = { show: true, invoice: null, + lnurl: {}, data: { - bolt11: '' + request: '' }, paymentChecker: null } @@ -253,7 +249,6 @@ new Vue({ }, 10000) }, closeSendDialog: function () { - // this.sendCamera.show = false var checker = this.send.paymentChecker setTimeout(function () { clearInterval(checker) @@ -290,29 +285,32 @@ new Vue({ }) }, decodeQR: function (res) { - if (res.substring(0, 4) == 'lnurl') { - console.log(res) - var self = this + this.send.data.request = res + this.decodeRequest() + this.sendCamera.show = false + }, + decodeRequest: function () { + if (this.send.data.request.startsWith('lightning:')) { + this.send.data.request = this.send.data.request.slice(10) + } + if (this.send.data.request.startsWith('lnurl:')) { + this.send.data.request = this.send.data.request.slice(6) + } + if (this.send.data.request.toLowerCase().startsWith('lnurl1')) { LNbits.api - .request('GET', '/lnurlscan/' + res, this.g.user.wallets[0].adminkey) + .request( + 'GET', + '/api/v1/lnurlscan/' + this.send.data.request, + this.g.user.wallets[0].adminkey + ) .then(function (response) { - console.log(response.data) + this.send.lnurl[response.kind] = Object.freeze(response) }) .catch(function (error) { - clearInterval(self.checker) LNbits.utils.notifyApiError(error) }) - } else { - this.send.data.bolt11 = res - this.decodeInvoice() - this.theCamera.show = false - } - }, - - decodeInvoice: function () { - if (this.send.data.bolt11.startsWith('lightning:')) { - this.send.data.bolt11 = this.send.data.bolt11.slice(10) + return } let invoice diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 5bf94da..bf94e96 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -17,7 +17,7 @@ color="deep-purple" class="full-width" @click="showSendDialog" - >Send
Paste Request
@@ -26,7 +26,7 @@ color="deep-purple" class="full-width" @click="showReceiveDialog" - >ReceiveCreate Invoice
-
- - - Renew keys -
LNbits wallet
- Wallet name: {{ wallet.name }}
- Wallet ID: {{ wallet.id }}
- Admin key: {{ wallet.adminkey }}
- Invoice/read key: {{ wallet.inkey }} -
- +
+ + + Renew keys +
LNbits wallet
+ Wallet name: {{ wallet.name }}
+ Wallet ID: {{ wallet.id }}
+ Admin key: {{ wallet.adminkey }}
+ Invoice/read key: {{ wallet.inkey }} +
+ + + + {% include "core/_api_docs.html" %} - - {% include "core/_api_docs.html" %} - - - - -

- This whole wallet will be deleted, the funds will be - UNRECOVERABLE. -

- Delete wallet -
-
-
-
-
-
-
+ + + +

+ This whole wallet will be deleted, the funds will be + UNRECOVERABLE. +

+ Delete wallet +
+
+
+ +
+
@@ -304,25 +311,25 @@
Read invoiceRead 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
@@ -383,7 +413,7 @@ - + diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 357bc2e..b46f40e 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -1,9 +1,15 @@ +<<<<<<< HEAD import trio # type: ignore import json 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 lnbits import bolt11 from lnbits.decorators import api_check_wallet_key, api_validate_post_request @@ -131,6 +137,7 @@ 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(): @@ -183,3 +190,36 @@ async def api_payments_sse(): ) response.timeout = None return response +======= + return jsonify({"paid": False}), HTTPStatus.OK + + +@core_app.route("/api/v1/lnurlscan/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_lnurlscan(code: str): + try: + url = lnurl.Lnurl(code) + except ValueError: + return jsonify({"error": "invalid lnurl"}), HTTPStatus.BAD_REQUEST + + domain = urlparse(url.url).netloc + if url.is_login: + return jsonify({"domain": domain, "kind": "auth", "error": "unsupported"}) + + data: lnurl.LnurlResponseModel = lnurl.get(url.url) + if not data.ok: + return jsonify({"domain": domain, "error": "failed to get parameters"}) + + if type(data) is lnurl.LnurlChannelResponse: + return jsonify({"domain": domain, "kind": "channel", "error": "unsupported"}) + + params = 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(domain=domain) + return jsonify(params) +>>>>>>> da8fd9a... send/create buttons wip. diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/display.html b/lnbits/extensions/lnurlp/templates/lnurlp/display.html index fd9b3de..a2e0389 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/display.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/display.html @@ -26,9 +26,7 @@
LNbits LNURL-pay link
-

- Use an LNURL compatible bitcoin wallet to pay. -

+

Use an LNURL compatible bitcoin wallet to pay.

From 3cd15c40fc7d4755ff12df5cfdb2ace7c1061602 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 11 Oct 2020 22:19:27 -0300 Subject: [PATCH 03/10] 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 -
-
- +
-
+
Payment proof:
{{ payment.preimage }}
+
+
Success action:
+
+ +
+
+
+ `, + computed: { + hasPreimage() { + return ( + this.payment.preimage && + this.payment.preimage !== + '0000000000000000000000000000000000000000000000000000000000000000' + ) + }, + hasSuccessAction() { + return ( + this.hasPreimage && + this.payment.extra && + this.payment.extra.success_action + ) + } + } +}) + +Vue.component('lnbits-lnurlpay-success-action', { + props: ['payment', 'success_action'], + data() { + return { + decryptedValue: this.success_action.ciphertext + } + }, + template: ` +
+

{{ success_action.message || success_action.description }}

+ + {{ decryptedValue }} + +

+ {{ success_action.url }} +

- ` + `, + mounted: function () { + if (this.success_action.tag !== 'aes') return null + + decryptLnurlPayAES(this.success_action, this.payment.preimage).then( + value => { + this.decryptedValue = value + } + ) + } }) diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index 8b7daf5..65629f7 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -34,7 +34,7 @@ class LNPayWallet(Wallet): f"Wallet {data['user_label']} (data['id']) not active, but {data['statusType']['name']}", 0 ) - return StatusResponse(None, data["balance"] / 1000) + return StatusResponse(None, data["balance"] * 1000) def create_invoice( self, diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index bce53cd..d9f3c4f 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -29,6 +29,8 @@ class SparkWallet(Wallet): params = args elif kwargs: params = kwargs + else: + params = {} r = httpx.post(self.url + "/rpc", headers={"X-Access": self.token}, json={"method": key, "params": params}) try: From bcdc065cc055b2d88e8297e102541c6af6b29a8a Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 13 Oct 2020 14:46:23 -0300 Subject: [PATCH 08/10] mypy fixes. --- lnbits/core/services.py | 9 +++------ lnbits/core/templates/core/wallet.html | 4 ++-- lnbits/core/views/api.py | 10 +++++----- lnbits/wallets/base.py | 2 +- lnbits/wallets/lnpay.py | 2 +- lnbits/wallets/opennode.py | 2 +- 6 files changed, 13 insertions(+), 16 deletions(-) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index d4d4d8c..a0b329a 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -1,7 +1,7 @@ import httpx from typing import Optional, Tuple, Dict from quart import g -from lnurl import LnurlWithdrawResponse +from lnurl import LnurlWithdrawResponse # type: ignore try: from typing import TypedDict # type: ignore @@ -116,7 +116,7 @@ def pay_invoice( else: # actually pay the external invoice payment: PaymentResponse = WALLET.pay_invoice(payment_request) - if payment.ok: + if payment.ok and payment.checking_id: create_payment( checking_id=payment.checking_id, fee=payment.fee_msat, @@ -132,13 +132,10 @@ def pay_invoice( async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo: Optional[str] = None) -> None: - if not memo: - memo = res.default_description - _, payment_request = create_invoice( wallet_id=wallet_id, amount=res.max_sats, - memo=memo, + memo=memo or res.default_description or "", extra={"tag": "lnurlwallet"}, ) diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 4edf134..a9169c3 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -347,8 +347,8 @@ {% raw %}

- {{ parse.lnurlpay.domain }} is requesting - {{ parse.lnurlpay.maxSendable | msatoshiFormat }} sat + {{ parse.lnurlpay.domain }} is requesting {{ + parse.lnurlpay.maxSendable | msatoshiFormat }} sat

{{ parse.lnurlpay.domain }} is requesting
diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 90526d7..fce82ae 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -1,6 +1,6 @@ import trio # type: ignore import json -import lnurl +import lnurl # type: ignore import httpx import traceback from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult @@ -306,11 +306,11 @@ async def api_lnurlscan(code: str): params.update(fixed=data.min_withdrawable == data.max_withdrawable) # callback with k1 already in it - url: ParseResult = urlparse(data.callback) - qs: Dict = parse_qs(url.query) + parsed_callback: ParseResult = urlparse(data.callback) + qs: Dict = parse_qs(parsed_callback.query) qs["k1"] = data.k1 - url = url._replace(query=urlencode(qs, doseq=True)) - params.update(callback=urlunparse(url)) + parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True)) + params.update(callback=urlunparse(parsed_callback)) if type(data) is lnurl.LnurlPayResponse: params.update(kind="pay") diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index 98f9137..d2486c7 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -32,7 +32,7 @@ class PaymentStatus(NamedTuple): class Wallet(ABC): @abstractmethod - def status() -> StatusResponse: + def status(self) -> StatusResponse: pass @abstractmethod diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index 65629f7..e1b6d44 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -23,7 +23,7 @@ class LNPayWallet(Wallet): try: r = httpx.get(url, headers=self.auth) except (httpx.ConnectError, httpx.RequestError): - return StatusResponse(f"Unable to connect to '{url}'") + return StatusResponse(f"Unable to connect to '{url}'", 0) if r.is_error: return StatusResponse(r.text[:250], 0) diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py index 8b772e7..4510e0d 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -24,7 +24,7 @@ class OpenNodeWallet(Wallet): try: r = httpx.get(f"{self.endpoint}/v1/account/balance", headers=self.auth) except (httpx.ConnectError, httpx.RequestError): - return StatusResponse(f"Unable to connect to '{self.endpoint}'") + return StatusResponse(f"Unable to connect to '{self.endpoint}'", 0) data = r.json()["message"] if r.is_error: From 1529ebb689f48745cda7338f0a5a471af37a5509 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 13 Oct 2020 15:18:34 -0300 Subject: [PATCH 09/10] lnurlpay comment. --- lnbits/core/static/js/wallet.js | 7 +++- lnbits/core/templates/core/wallet.html | 54 +++++++++++++++++++------- lnbits/core/views/api.py | 10 ++++- lnbits/static/js/base.js | 4 +- 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 661d2e2..0b08bcc 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -129,7 +129,8 @@ new Vue({ lnurlpay: null, data: { request: '', - amount: 0 + amount: 0, + comment: '' }, paymentChecker: null, camera: { @@ -235,6 +236,7 @@ new Vue({ this.parse.invoice = null this.parse.lnurlpay = null this.parse.data.request = '' + this.parse.data.comment = '' this.parse.data.paymentChecker = null this.parse.camera.show = false }, @@ -449,7 +451,8 @@ new Vue({ this.parse.lnurlpay.callback, this.parse.lnurlpay.description_hash, this.parse.data.amount * 1000, - this.parse.lnurlpay.description.slice(0, 120) + this.parse.lnurlpay.description.slice(0, 120), + this.parse.data.comment ) .then(response => { this.parse.show = false diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index a9169c3..5c95f37 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -349,27 +349,53 @@

{{ parse.lnurlpay.domain }} is requesting {{ parse.lnurlpay.maxSendable | msatoshiFormat }} sat + +
+ and a {{parse.lnurlpay.commentAllowed}}-char comment +

{{ parse.lnurlpay.domain }} is requesting
between {{ parse.lnurlpay.minSendable | msatoshiFormat }} and {{ parse.lnurlpay.maxSendable | msatoshiFormat }} sat + +
+ and a {{parse.lnurlpay.commentAllowed}}-char comment +

-

{{ parse.lnurlpay.description }}

-

- -

- +
+

+ {{ parse.lnurlpay.description }} +

+

+ +

+
+
+
+ +
+
+ +
+
Send satoshis Date: Tue, 13 Oct 2020 15:20:58 -0300 Subject: [PATCH 10/10] fix bug with lnurl-withdraw dialog being skipped directly to the previous invoice shown. --- lnbits/core/static/js/wallet.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 0b08bcc..fbcf81c 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -353,7 +353,7 @@ new Vue({ this.parse.show = false this.receive.show = true this.receive.status = 'pending' - this.paymentReq = null + this.receive.paymentReq = null this.receive.data.amount = data.maxWithdrawable / 1000 this.receive.data.memo = data.defaultDescription this.receive.minMax = [
@@ -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. From bc2207ba278ef6597b9361965e74847b69def1a7 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Mon, 12 Oct 2020 18:15:27 -0300 Subject: [PATCH 04/10] actually paying and withdrawing with lnurl. --- lnbits/core/services.py | 9 ++- lnbits/core/static/js/wallet.js | 100 +++++++++++++++++------ lnbits/core/templates/core/wallet.html | 9 ++- lnbits/core/views/api.py | 105 ++++++++++++++++++++++++- lnbits/static/js/base.js | 19 ++++- 5 files changed, 207 insertions(+), 35 deletions(-) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 8bdb73a..d4d4d8c 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -51,7 +51,12 @@ def create_invoice( def pay_invoice( - *, wallet_id: str, payment_request: str, max_sat: Optional[int] = None, extra: Optional[Dict] = None + *, + wallet_id: str, + payment_request: str, + max_sat: Optional[int] = None, + extra: Optional[Dict] = None, + description: str = "", ) -> str: temp_id = f"temp_{urlsafe_short_hash()}" internal_id = f"internal_{urlsafe_short_hash()}" @@ -79,7 +84,7 @@ def pay_invoice( payment_request=payment_request, payment_hash=invoice.payment_hash, amount=-invoice.amount_msat, - memo=invoice.description or "", + memo=description or invoice.description or "", extra=extra, ) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index b39a25d..72f54d4 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -256,16 +256,36 @@ new Vue({ .createInvoice( this.g.wallet, this.receive.data.amount, - this.receive.data.memo + this.receive.data.memo, + this.receive.lnurl && this.receive.lnurl.callback ) .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) + if (response.data.lnurl_response !== null) { + if (response.data.lnurl_response === false) { + response.data.lnurl_response = `Unable to connect` + } + + if (typeof response.data.lnurl_response === 'string') { + // failure + this.$q.notify({ + timeout: 5000, + type: 'negative', + message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`, + caption: response.data.lnurl_response + }) + return + } else if (response.data.lnurl_response === true) { + // success + this.$q.notify({ + timeout: 5000, + type: 'positive', + message: `Invoice sent to ${this.receive.lnurl.domain}!`, + spinner: true + }) + } } this.receive.paymentChecker = setInterval(() => { @@ -274,6 +294,7 @@ new Vue({ .then(response => { if (response.data.paid) { this.fetchPayments() + this.fetchBalance() this.receive.show = false clearInterval(this.receive.paymentChecker) } @@ -314,12 +335,11 @@ new Vue({ let data = response.data if (data.status === 'ERROR') { - Quasar.plugins.Notify.create({ + this.$q.notify({ timeout: 5000, type: 'warning', - message: data.reason, - caption: `${data.domain} returned an error to the lnurl call.`, - icon: null + message: `${data.domain} lnurl call failed.`, + caption: data.reason }) return } @@ -331,13 +351,16 @@ new Vue({ this.parse.show = false this.receive.show = true this.receive.status = 'pending' - this.receive.data.amount = data.maxWithdrawable + this.paymentReq = null + this.receive.data.amount = data.maxWithdrawable / 1000 this.receive.data.memo = data.defaultDescription - this.receive.minMax = [data.minWithdrawable, data.maxWithdrawable] + this.receive.minMax = [ + data.minWithdrawable / 1000, + data.maxWithdrawable / 1000 + ] this.receive.lnurl = { domain: data.domain, callback: data.callback, - k1: data.k1, fixed: data.fixed } } @@ -353,8 +376,7 @@ new Vue({ timeout: 3000, type: 'warning', message: error + '.', - caption: '400 BAD REQUEST', - icon: null + caption: '400 BAD REQUEST' }) this.parse.show = false return @@ -390,8 +412,7 @@ new Vue({ payInvoice: function () { let dismissPaymentMsg = this.$q.notify({ timeout: 0, - message: 'Processing payment...', - icon: null + message: 'Processing payment...' }) LNbits.api @@ -406,6 +427,7 @@ new Vue({ clearInterval(this.parse.paymentChecker) dismissPaymentMsg() this.fetchPayments() + this.fetchBalance() } }) }, 2000) @@ -418,22 +440,55 @@ new Vue({ payLnurl: function () { let dismissPaymentMsg = this.$q.notify({ timeout: 0, - message: 'Processing payment...', - icon: null + message: 'Processing payment...' }) LNbits.api - .payInvoice(this.g.wallet, this.parse.data.bolt11) + .payLnurl( + this.g.wallet, + this.parse.lnurlpay.callback, + this.parse.lnurlpay.description_hash, + this.parse.data.amount * 1000, + this.parse.lnurlpay.description.slice(0, 120) + ) .then(response => { + this.parse.show = false + 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() + clearInterval(this.parse.paymentChecker) this.fetchPayments() + this.fetchBalance() + + // show lnurlpay success action + if (response.data.success_action) { + switch (response.data.success_action.tag) { + case 'url': + this.$q.notify({ + message: `${response.data.success_action.url}`, + caption: response.data.success_action.description, + html: true, + type: 'info', + timeout: 0, + closeBtn: true + }) + break + case 'message': + this.$q.notify({ + message: response.data.success_action.message, + type: 'info', + timeout: 0, + closeBtn: true + }) + break + case 'aes': + break + } + } } }) }, 2000) @@ -475,8 +530,7 @@ new Vue({ checkPendingPayments: function () { var dismissMsg = this.$q.notify({ timeout: 0, - message: 'Checking pending transactions...', - icon: null + message: 'Checking pending transactions...' }) this.fetchPayments(true).then(() => { diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 405f970..4edf134 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -130,7 +130,7 @@ {{ props.row.fsat }} - + {{ props.row.fee }} @@ -266,8 +266,8 @@ v-model.number="receive.data.amount" type="number" label="Amount (sat) *" - min="receive.minMax[0]" - max="receive.minMax[1]" + :min="receive.minMax[0]" + :max="receive.minMax[1]" :readonly="receive.lnurl && receive.lnurl.fixed" >

- {{ parse.lnurlpay.maxSendable | msatoshiFormat }} + {{ parse.lnurlpay.domain }} is requesting + {{ parse.lnurlpay.maxSendable | msatoshiFormat }} sat

{{ parse.lnurlpay.domain }} is requesting
diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 5be279e..7ff1e88 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -3,11 +3,11 @@ import json import lnurl import httpx import traceback +from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult from quart import g, jsonify, request, make_response from http import HTTPStatus from binascii import unhexlify -from urllib.parse import urlparse -from typing import Dict +from typing import Dict, Union from lnbits import bolt11 from lnbits.decorators import api_check_wallet_key, api_validate_post_request @@ -51,6 +51,7 @@ async def api_payments(): "amount": {"type": "integer", "min": 1, "required": True}, "memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"}, "description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"}, + "lnurl_callback": {"type": "string", "empty": False, "required": False}, } ) async def api_payments_create_invoice(): @@ -70,6 +71,23 @@ async def api_payments_create_invoice(): return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR invoice = bolt11.decode(payment_request) + + lnurl_response: Union[None, bool, str] = None + if "lnurl_callback" in g.data: + print(g.data["lnurl_callback"]) + try: + r = httpx.get(g.data["lnurl_callback"], params={"pr": payment_request}, timeout=10) + if r.is_error: + lnurl_response = r.text + else: + resp = json.loads(r.text) + if resp["status"] != "OK": + lnurl_response = resp["reason"] + else: + lnurl_response = True + except httpx.RequestError: + lnurl_response = False + return ( jsonify( { @@ -77,6 +95,7 @@ async def api_payments_create_invoice(): "payment_request": payment_request, # maintain backwards compatibility with API clients: "checking_id": invoice.payment_hash, + "lnurl_response": lnurl_response, } ), HTTPStatus.CREATED, @@ -117,6 +136,74 @@ async def api_payments_create(): return await api_payments_create_invoice() +@core_app.route("/api/v1/payments/lnurl", methods=["POST"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "description_hash": {"type": "string", "empty": False, "required": True}, + "callback": {"type": "string", "empty": False, "required": True}, + "amount": {"type": "number", "empty": False, "required": True}, + "description": {"type": "string", "empty": True, "required": False}, + } +) +async def api_payments_pay_lnurl(): + try: + r = httpx.get(g.data["callback"], params={"amount": g.data["amount"]}, timeout=20) + if r.is_error: + return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST + except httpx.RequestError: + return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST + + params = json.loads(r.text) + if params.get("status") == "ERROR": + domain = urlparse(g.data["callback"]).netloc + return jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}), HTTPStatus.BAD_REQUEST + + invoice = bolt11.decode(params["pr"]) + if invoice.amount_msat != g.data["amount"]: + return ( + jsonify( + { + "message": f"{domain} returned an invalid invoice. Expected {g.data['amount']} msat, got {invoice.amount_msat}." + } + ), + HTTPStatus.BAD_REQUEST, + ) + if invoice.description_hash != g.data["description_hash"]: + return ( + jsonify( + { + "message": f"{domain} returned an invalid invoice. Expected description_hash == {g.data['description_hash']}, got {invoice.description_hash}." + } + ), + HTTPStatus.BAD_REQUEST, + ) + + try: + payment_hash = pay_invoice( + wallet_id=g.wallet.id, + payment_request=params["pr"], + description=g.data.get("description", ""), + extra={"success_action": params.get("successAction")}, + ) + except Exception as exc: + traceback.print_exc(7) + g.db.rollback() + return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR + + return ( + jsonify( + { + "success_action": params.get("successAction"), + "payment_hash": payment_hash, + # maintain backwards compatibility with API clients: + "checking_id": payment_hash, + } + ), + HTTPStatus.CREATED, + ) + + @core_app.route("/api/v1/payments/", methods=["GET"]) @api_check_wallet_key("invoice") async def api_payment(payment_hash): @@ -216,10 +303,20 @@ async def api_lnurlscan(code: str): params: Dict = data.dict() if type(data) is lnurl.LnurlWithdrawResponse: - params.update(kind="withdraw", fixed=data.min_withdrawable == data.max_withdrawable) + params.update(kind="withdraw") + params.update(fixed=data.min_withdrawable == data.max_withdrawable) + + # callback with k1 already in it + url: ParseResult = urlparse(data.callback) + qs: Dict = parse_qs(url.query) + qs["k1"] = data.k1 + url = url._replace(query=urlencode(qs, doseq=True)) + params.update(callback=urlunparse(url)) if type(data) is lnurl.LnurlPayResponse: - params.update(kind="pay", fixed=data.min_sendable == data.max_sendable) + params.update(kind="pay") + params.update(fixed=data.min_sendable == data.max_sendable) + params.update(description_hash=data.metadata.h) params.update(description=data.metadata.text) if data.metadata.images: image = min(data.metadata.images, key=lambda image: len(image[1])) diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index abaec60..9f1bb28 100644 --- a/lnbits/static/js/base.js +++ b/lnbits/static/js/base.js @@ -16,11 +16,12 @@ var LNbits = { data: data }) }, - createInvoice: function (wallet, amount, memo) { + createInvoice: function (wallet, amount, memo, lnurlCallback = null) { return this.request('post', '/api/v1/payments', wallet.inkey, { out: false, amount: amount, - memo: memo + memo: memo, + lnurl_callback: lnurlCallback }) }, payInvoice: function (wallet, bolt11) { @@ -29,6 +30,20 @@ var LNbits = { bolt11: bolt11 }) }, + payLnurl: function ( + wallet, + callback, + description_hash, + amount, + description = '' + ) { + return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, { + callback, + description_hash, + amount, + description + }) + }, getWallet: function (wallet) { return this.request('get', '/api/v1/wallet', wallet.inkey) }, From 69063190ab89f2fa790745456bd342b5d907f638 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Mon, 12 Oct 2020 22:25:55 -0300 Subject: [PATCH 05/10] also catch httpx.ConnectError whenever we do catch httpx.RequestError. --- lnbits/core/views/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 7ff1e88..25c1ff4 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -85,7 +85,7 @@ async def api_payments_create_invoice(): lnurl_response = resp["reason"] else: lnurl_response = True - except httpx.RequestError: + except (httpx.ConnectError, httpx.RequestError): lnurl_response = False return ( @@ -151,7 +151,7 @@ async def api_payments_pay_lnurl(): r = httpx.get(g.data["callback"], params={"amount": g.data["amount"]}, timeout=20) if r.is_error: return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST - except httpx.RequestError: + except (httpx.ConnectError, httpx.RequestError): return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST params = json.loads(r.text) From 8d135489abd4eaf24a3e53a0594184a363569775 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Mon, 12 Oct 2020 23:17:50 -0300 Subject: [PATCH 06/10] fix: invoice parsing JS bug from the lnurl implementation. --- lnbits/core/static/js/wallet.js | 4 ++-- lnbits/core/views/api.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 72f54d4..21f058d 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -370,7 +370,7 @@ new Vue({ let invoice try { - invoice = decode(this.parse.data.bolt11) + invoice = decode(this.parse.data.request) } catch (error) { this.$q.notify({ timeout: 3000, @@ -416,7 +416,7 @@ new Vue({ }) LNbits.api - .payInvoice(this.g.wallet, this.parse.data.bolt11) + .payInvoice(this.g.wallet, this.parse.data.request) .then(response => { this.parse.paymentChecker = setInterval(() => { LNbits.api diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 25c1ff4..1e292dc 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -51,7 +51,7 @@ async def api_payments(): "amount": {"type": "integer", "min": 1, "required": True}, "memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"}, "description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"}, - "lnurl_callback": {"type": "string", "empty": False, "required": False}, + "lnurl_callback": {"type": "string", "nullable": True, "required": False}, } ) async def api_payments_create_invoice(): @@ -73,8 +73,7 @@ async def api_payments_create_invoice(): invoice = bolt11.decode(payment_request) lnurl_response: Union[None, bool, str] = None - if "lnurl_callback" in g.data: - print(g.data["lnurl_callback"]) + if g.data.get("lnurl_callback"): try: r = httpx.get(g.data["lnurl_callback"], params={"pr": payment_request}, timeout=10) if r.is_error: From cf0bd7ece8d449d801fc3c6633529393c3d4fa2c Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 13 Oct 2020 13:57:26 -0300 Subject: [PATCH 07/10] displaying lnurlpay success_actions. --- lnbits/core/static/js/wallet.js | 26 ++++++++++++-- lnbits/core/views/api.py | 4 +-- lnbits/static/js/base.js | 50 +++++++++++++++++++-------- lnbits/static/js/components.js | 60 +++++++++++++++++++++++++++++++-- lnbits/wallets/lnpay.py | 2 +- lnbits/wallets/spark.py | 2 ++ 6 files changed, 121 insertions(+), 23 deletions(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 21f058d..661d2e2 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -1,4 +1,4 @@ -/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart */ +/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart, decryptLnurlPayAES */ Vue.component(VueQrcode.name, VueQrcode) Vue.use(VueQrcodeReader) @@ -248,7 +248,7 @@ new Vue({ var checker = this.parse.paymentChecker setTimeout(() => { clearInterval(checker) - }, 1000) + }, 10000) }, createInvoice: function () { this.receive.status = 'loading' @@ -469,7 +469,7 @@ new Vue({ switch (response.data.success_action.tag) { case 'url': this.$q.notify({ - message: `${response.data.success_action.url}`, + message: `${response.data.success_action.url}`, caption: response.data.success_action.description, html: true, type: 'info', @@ -486,6 +486,26 @@ new Vue({ }) break case 'aes': + LNbits.api + .getPayment(this.g.wallet, response.data.payment_hash) + .then( + ({data: payment}) => + console.log(payment) || + decryptLnurlPayAES( + response.data.success_action, + payment.preimage + ) + ) + .then(value => { + this.$q.notify({ + message: value, + caption: response.data.success_action.description, + html: true, + type: 'info', + timeout: 0, + closeBtn: true + }) + }) break } } diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 1e292dc..90526d7 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -211,14 +211,14 @@ async def api_payment(payment_hash): if not payment: return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND elif not payment.pending: - return jsonify({"paid": True}), HTTPStatus.OK + return jsonify({"paid": True, "preimage": payment.preimage}), HTTPStatus.OK try: payment.check_pending() except Exception: return jsonify({"paid": False}), HTTPStatus.OK - return jsonify({"paid": not payment.pending}), HTTPStatus.OK + return jsonify({"paid": not payment.pending, "preimage": payment.preimage}), HTTPStatus.OK @core_app.route("/api/v1/payments/sse", methods=["GET"]) diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index 9f1bb28..c1bdbb9 100644 --- a/lnbits/static/js/base.js +++ b/lnbits/static/js/base.js @@ -1,10 +1,8 @@ -/* globals moment, Vue, EventHub, axios, Quasar, _ */ +/* globals crypto, moment, Vue, axios, Quasar, _ */ -var LOCALE = 'en' - -var EventHub = new Vue() - -var LNbits = { +window.LOCALE = 'en' +window.EventHub = new Vue() +window.LNbits = { api: { request: function (method, url, apiKey, data) { return axios({ @@ -106,7 +104,7 @@ var LNbits = { ) obj.msat = obj.balance obj.sat = Math.round(obj.balance / 1000) - obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.sat) + obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat) obj.url = ['/wallet?usr=', obj.user, '&wal=', obj.id].join('') return obj }, @@ -134,7 +132,7 @@ var LNbits = { obj.msat = obj.amount obj.sat = obj.msat / 1000 obj.tag = obj.extra.tag - obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.sat) + obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat) obj.isIn = obj.amount > 0 obj.isOut = obj.amount < 0 obj.isPaid = obj.pending === 0 @@ -157,13 +155,13 @@ var LNbits = { }) }, formatCurrency: function (value, currency) { - return new Intl.NumberFormat(LOCALE, { + return new Intl.NumberFormat(window.LOCALE, { style: 'currency', currency: currency }).format(value) }, formatSat: function (value) { - return new Intl.NumberFormat(LOCALE).format(value) + return new Intl.NumberFormat(window.LOCALE).format(value) }, notifyApiError: function (error) { var types = { @@ -246,7 +244,7 @@ var LNbits = { } } -var windowMixin = { +window.windowMixin = { data: function () { return { g: { @@ -276,17 +274,17 @@ var windowMixin = { created: function () { this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode')) if (window.user) { - this.g.user = Object.freeze(LNbits.map.user(window.user)) + this.g.user = Object.freeze(window.LNbits.map.user(window.user)) } if (window.wallet) { - this.g.wallet = Object.freeze(LNbits.map.wallet(window.wallet)) + this.g.wallet = Object.freeze(window.LNbits.map.wallet(window.wallet)) } if (window.extensions) { var user = this.g.user this.g.extensions = Object.freeze( window.extensions .map(function (data) { - return LNbits.map.extension(data) + return window.LNbits.map.extension(data) }) .map(function (obj) { if (user) { @@ -303,3 +301,27 @@ var windowMixin = { } } } + +window.decryptLnurlPayAES = function (success_action, preimage) { + let keyb = new Uint8Array( + preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16)) + ) + + return crypto.subtle + .importKey('raw', keyb, {name: 'AES-CBC', length: 256}, false, ['decrypt']) + .then(key => { + let ivb = Uint8Array.from(window.atob(success_action.iv), c => + c.charCodeAt(0) + ) + let ciphertextb = Uint8Array.from( + window.atob(success_action.ciphertext), + c => c.charCodeAt(0) + ) + + return crypto.subtle.decrypt({name: 'AES-CBC', iv: ivb}, key, ciphertextb) + }) + .then(valueb => { + let decoder = new TextDecoder('utf-8') + return decoder.decode(valueb) + }) +} diff --git a/lnbits/static/js/components.js b/lnbits/static/js/components.js index 3d8c554..0d37f52 100644 --- a/lnbits/static/js/components.js +++ b/lnbits/static/js/components.js @@ -1,4 +1,4 @@ -/* global Vue, moment, LNbits, EventHub */ +/* global Vue, moment, LNbits, EventHub, decryptLnurlPayAES */ Vue.component('lnbits-fsat', { props: { @@ -199,10 +199,64 @@ Vue.component('lnbits-payment-details', {

Payment hash:
{{ payment.payment_hash }}