lnurl-pay and lnurl-withdraw UI.

This commit is contained in:
fiatjaf 2020-10-11 22:19:27 -03:00
parent 7a5159f293
commit 3cd15c40fc
3 changed files with 260 additions and 174 deletions

View File

@ -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
self.receive.paymentChecker = setInterval(function () {
if (this.receive.lnurl) {
// send invoice to lnurl callback
console.log('sending', this.receive.lnurl)
LNbits.api.sendInvoiceToLnurlWithdraw(this.receive.paymentReq)
}
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,9 @@ 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...',
@ -364,55 +395,80 @@ 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)
})
},
payLnurl: function () {
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)
})
},
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()
})
},

View File

@ -16,7 +16,7 @@
unelevated
color="deep-purple"
class="full-width"
@click="showSendDialog"
@click="showParseDialog"
>Paste Request</q-btn
>
</div>
@ -249,18 +249,26 @@
</q-card>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
{% raw %}
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
</p>
<q-input
filled
dense
v-model.number="receive.data.amount"
type="number"
label="Amount (sat) *"
min="receive.minMax[0]"
max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
<q-input
filled
@ -275,8 +283,12 @@
color="deep-purple"
:disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit"
>Create invoice</q-btn
>
<span v-if="receive.lnurl">
Withdraw from {{receive.lnurl.domain}}
</span>
<span v-else> Create invoice </span>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<q-spinner
@ -305,20 +317,79 @@
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
{% endraw %}
</q-dialog>
<q-dialog v-model="send.show" position="top" @hide="closeSendDialog">
<q-dialog v-model="parse.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!send.invoice">
<div v-if="parse.invoice">
{% raw %}
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator>
<p style="word-break: break-all">
<strong>Description:</strong> {{ parse.invoice.description }}<br />
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ parse.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="deep-purple" @click="payInvoice">Pay</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black"
>Not enough funds!</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
<div v-else-if="parse.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
{{ parse.lnurlpay.maxSendable | msatoshiFormat }}
</p>
<p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.domain }}</b> is requesting <br />
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b> sat
</p>
<q-separator class="q-my-sm"></q-separator>
<p class="text-justify text-italic">{{ parse.lnurlpay.description }}</p>
<p v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" width="50%" />
</p>
<q-input
filled
dense
v-model.number="parse.data.amount"
type="number"
label="Amount (sat) *"
min="parse.lnurlpay.minSendable / 1000"
max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay.fixed"
></q-input>
<div class="row q-mt-lg">
<q-btn unelevated color="deep-purple" type="submit"
>Send satoshis</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else>
<q-form
v-if="!theCamera.show"
@submit="decodeInvoice"
v-if="!parse.camera.show"
@submit="decodeRequest"
class="q-gutter-md"
>
<q-input
filled
dense
v-model.trim="send.data.request"
v-model.trim="parse.data.request"
type="textarea"
label="Paste an invoice, payment request or lnurl code *"
>
@ -327,7 +398,7 @@
<q-btn
unelevated
color="deep-purple"
:disable="send.data.request == ''"
:disable="parse.data.request == ''"
type="submit"
>Read</q-btn
>
@ -344,62 +415,16 @@
></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
Cancel
</q-btn>
</div>
</div>
</div>
<div v-else-if="send.lnurl.withdraw">
{% raw %}
<h6 class="q-my-none">{{ send.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator>
<p style="word-break: break-all">
<strong>Description:</strong> {{ send.invoice.description }}<br />
<strong>Payment hash:</strong> {{ send.invoice.hash }}<br />
<strong>Expire date:</strong> {{ send.invoice.expireDate }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="deep-purple" @click="payInvoice"
>Send satoshis</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black"
>Not enough funds!</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
<div v-else>
{% raw %}
<h6 class="q-my-none">{{ send.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator>
<p style="word-break: break-all">
<strong>Description:</strong> {{ send.invoice.description }}<br />
<strong>Expire date:</strong> {{ send.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ send.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="deep-purple" @click="payInvoice"
>Send satoshis</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black"
>Not enough funds!</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="theCamera.show" position="top">
<q-dialog v-model="parse.camera.show">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
@ -412,7 +437,7 @@
</q-card>
</q-dialog>
<q-dialog v-model="paymentsChart.show" position="top">
<q-dialog v-model="paymentsChart.show">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas>

View File

@ -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/<code>", 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.