diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 8bdb73ac..a0b329a6 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 @@ -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, ) @@ -111,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, @@ -127,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/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 88181961..fbcf81c3 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, decryptLnurlPayAES */ Vue.component(VueQrcode.name, VueQrcode) Vue.use(VueQrcodeReader) @@ -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,22 +116,28 @@ new Vue({ show: false, status: 'pending', paymentReq: null, + minMax: [0, 2100000000000000], + lnurl: null, data: { amount: null, memo: '' } }, - send: { + parse: { show: false, invoice: null, + lnurlpay: null, data: { - bolt11: '' + request: '', + amount: 0, + comment: '' + }, + paymentChecker: null, + camera: { + show: false, + camera: 'auto' } }, - sendCamera: { - show: false, - camera: 'auto' - }, payments: [], paymentsTable: { columns: [ @@ -196,8 +194,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 @@ -205,105 +203,184 @@ new Vue({ : false } }, + filters: { + msatoshiFormat: function (value) { + return LNbits.utils.formatSat(value / 1000) + } + }, methods: { closeCamera: function () { - this.sendCamera.show = false + this.parse.camera.show = false }, showCamera: function () { - this.sendCamera.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, - data: { - bolt11: '' - }, - paymentChecker: null - } + showParseDialog: function () { + this.parse.show = true + 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 }, closeReceiveDialog: function () { var checker = this.receive.paymentChecker - setTimeout(function () { + setTimeout(() => { clearInterval(checker) }, 10000) }, - closeSendDialog: function () { - this.sendCamera.show = false - var checker = this.send.paymentChecker - setTimeout(function () { + closeParseDialog: function () { + var checker = this.parse.paymentChecker + setTimeout(() => { clearInterval(checker) - }, 1000) + }, 10000) }, createInvoice: function () { - var self = this this.receive.status = 'loading' LNbits.api .createInvoice( this.g.wallet, this.receive.data.amount, - this.receive.data.memo + this.receive.data.memo, + this.receive.lnurl && this.receive.lnurl.callback ) - .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 - self.receive.paymentChecker = setInterval(function () { + 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(() => { 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.fetchBalance() + 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.bolt11 = res - this.decodeInvoice() - this.sendCamera.show = false + this.parse.data.request = res + this.decodeRequest() + this.parse.camera.show = false }, - decodeInvoice: function () { - if (this.send.data.bolt11.startsWith('lightning:')) { - this.send.data.bolt11 = this.send.data.bolt11.slice(10) + decodeRequest: function () { + this.parse.show = true + + if (this.parse.data.request.startsWith('lightning:')) { + this.parse.data.request = this.parse.data.request.slice(10) + } + if (this.parse.data.request.startsWith('lnurl:')) { + this.parse.data.request = this.parse.data.request.slice(6) + } + + if (this.parse.data.request.toLowerCase().startsWith('lnurl1')) { + LNbits.api + .request( + 'GET', + '/api/v1/lnurlscan/' + this.parse.data.request, + this.g.user.wallets[0].adminkey + ) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + .then(response => { + let data = response.data + + if (data.status === 'ERROR') { + this.$q.notify({ + timeout: 5000, + type: 'warning', + message: `${data.domain} lnurl call failed.`, + caption: data.reason + }) + 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.paymentReq = null + this.receive.data.amount = data.maxWithdrawable / 1000 + this.receive.data.memo = data.defaultDescription + this.receive.minMax = [ + data.minWithdrawable / 1000, + data.maxWithdrawable / 1000 + ] + this.receive.lnurl = { + domain: data.domain, + callback: data.callback, + fixed: data.fixed + } + } + }) + return } let invoice try { - invoice = decode(this.send.data.bolt11) + invoice = decode(this.parse.data.request) } catch (error) { this.$q.notify({ timeout: 3000, type: 'warning', message: error + '.', - caption: '400 BAD REQUEST', - icon: null + caption: '400 BAD REQUEST' }) + this.parse.show = false return } @@ -313,7 +390,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 @@ -332,78 +409,154 @@ 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 + message: 'Processing payment...' }) 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.request) + .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() + this.fetchBalance() } }) }, 2000) }) - .catch(function (error) { + .catch(err => { dismissPaymentMsg() - LNbits.utils.notifyApiError(error) + LNbits.utils.notifyApiError(err) + }) + }, + payLnurl: function () { + let dismissPaymentMsg = this.$q.notify({ + timeout: 0, + message: 'Processing payment...' + }) + + LNbits.api + .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), + this.parse.data.comment + ) + .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) { + 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': + 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 + } + } + } + }) + }, 2000) + }) + .catch(err => { + dismissPaymentMsg() + 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 ]) }) }, checkPendingPayments: function () { var dismissMsg = this.$q.notify({ timeout: 0, - message: 'Checking pending transactions...', - icon: null + message: 'Checking pending transactions...' }) - 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 5d620467..5c95f377 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -14,10 +14,10 @@
SendPaste Request
@@ -26,9 +26,19 @@ color="deep-purple" class="full-width" @click="showReceiveDialog" - >ReceiveCreate Invoice
+
+ scan + Use camera to scan an invoice/QR + +
@@ -120,7 +130,7 @@ {{ props.row.fsat }} - + {{ props.row.fee }} @@ -131,7 +141,9 @@
Invoice waiting to be paid - +
Payment Sent - +
Outgoing payment pending - +
@@ -187,62 +205,70 @@ - -
- - - 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" %} - - - - -

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

- Delete wallet -
-
-
-
-
-
-
- +
+ + + 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" %} + + + + +

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

+ Delete wallet +
+
+
+
+
+
+
+ + + {% 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.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 }} +

+

+ +

+
+
+
+ +
+
+ +
+
+
+ Send satoshis + Cancel +
+
+ {% endraw %} +
+
-
Read invoiceRead Cancel
- Cancel + + Cancel +
-
- {% raw %} -
{{ send.invoice.fsat }} sat
- -

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

- {% endraw %} -
- Send satoshis - Cancel -
-
- Not enough funds! - Cancel -
-
- + + +
+ +
+
+ Cancel +
+
+
+ + diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 357bc2ed..a8c3fb9c 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -1,9 +1,13 @@ import trio # type: ignore import json +import lnurl # type: ignore +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 typing import Dict, Union from lnbits import bolt11 from lnbits.decorators import api_check_wallet_key, api_validate_post_request @@ -47,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", "nullable": True, "required": False}, } ) async def api_payments_create_invoice(): @@ -66,6 +71,22 @@ 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 g.data.get("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.ConnectError, httpx.RequestError): + lnurl_response = False + return ( jsonify( { @@ -73,6 +94,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, @@ -113,6 +135,79 @@ 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}, + "comment": {"type": "string", "nullable": True, "empty": True, "required": False}, + "description": {"type": "string", "nullable": True, "empty": True, "required": False}, + } +) +async def api_payments_pay_lnurl(): + try: + r = httpx.get( + g.data["callback"], + params={"amount": g.data["amount"], "comment": g.data["comment"]}, + timeout=40, + ) + if r.is_error: + return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST + except (httpx.ConnectError, 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): @@ -121,14 +216,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"]) @@ -183,3 +278,55 @@ async def api_payments_sse(): ) response.timeout = None return response + + +@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"}) + + 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: Dict = data.dict() + if type(data) is lnurl.LnurlWithdrawResponse: + params.update(kind="withdraw") + params.update(fixed=data.min_withdrawable == data.max_withdrawable) + + # callback with k1 already in it + parsed_callback: ParseResult = urlparse(data.callback) + qs: Dict = parse_qs(parsed_callback.query) + qs["k1"] = data.k1 + 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") + 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])) + data_uri = "data:" + image[0] + "," + image[1] + params.update(image=data_uri) + params.update(commentAllowed=jdata.get("commentAllowed", 0)) + + params.update(domain=domain) + return jsonify(params) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/display.html b/lnbits/extensions/lnurlp/templates/lnurlp/display.html index fd9b3dee..a2e0389c 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.

diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index abaec60c..0cad91b9 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({ @@ -16,11 +14,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 +28,22 @@ var LNbits = { bolt11: bolt11 }) }, + payLnurl: function ( + wallet, + callback, + description_hash, + amount, + description = '', + comment = '' + ) { + return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, { + callback, + description_hash, + amount, + comment, + description + }) + }, getWallet: function (wallet) { return this.request('get', '/api/v1/wallet', wallet.inkey) }, @@ -91,7 +106,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 }, @@ -119,7 +134,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 @@ -142,13 +157,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 = { @@ -231,7 +246,7 @@ var LNbits = { } } -var windowMixin = { +window.windowMixin = { data: function () { return { g: { @@ -261,17 +276,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) { @@ -288,3 +303,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 3d8c5547..0d37f520 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 }}
-
+
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/base.py b/lnbits/wallets/base.py index 98f91378..d2486c73 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 8b7daf5e..e1b6d446 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) @@ -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/opennode.py b/lnbits/wallets/opennode.py index 8b772e7e..4510e0d3 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: diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index bce53cd4..d9f3c4f3 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: