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 @@
- This whole wallet will be deleted, the funds will be - UNRECOVERABLE. -
-+ This whole wallet will be deleted, the funds will be + UNRECOVERABLE. +
++ {{receive.lnurl.domain}} is requesting an invoice: +
+
+ Description: {{ parse.invoice.description }}
+ Expire date: {{ parse.invoice.expireDate }}
+ Hash: {{ parse.invoice.hash }}
+
+ {{ 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 }} +
+
+
- Memo: {{ send.invoice.description }}
- Expire date: {{ send.invoice.expireDate }}
- Hash: {{ send.invoice.hash }}
-
", 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 }}
{{ success_action.message || success_action.description }}
+
+ {{ decryptedValue }}
+
+
+