lnbits-legend/lnbits/extensions/cashu/templates/cashu/wallet.html
2022-12-01 12:21:25 +01:00

1712 lines
56 KiB
HTML

{% extends "public.html" %} {% block toolbar_title %} {% raw %} {{name}} Cashu {% endraw %} {% endblock %} {% block footer %}{% endblock %} {% block
page_container %}
<q-page-container>
<q-page>
<div class="row q-col-gutter-md justify-center q-pt-lg">
<div class="col-12 col-sm-8 col-md-9 col-lg-7 text-center q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row">
<div class="col-3">
<q-btn
class="gt-sm"
size="16px"
icon="arrow_downward"
rectangle
color="secondary"
class="full-width"
@click="showInvoicesDialog"
>Receive invoice
</q-btn>
</div>
<div class="col-6">
<h3 class="q-my-none">
<center>
<strong>{% raw %} {{getBalance()}} </strong>
{{tickershort}}{% endraw %}
</center>
</h3>
</div>
<div class="col-3">
<q-btn
class="gt-sm"
@click="showParseDialog"
size="16px"
icon="arrow_upward"
rectangle
color="secondary"
class="full-width"
>Pay invoice
</q-btn>
</div>
</div>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col-5 col-sm-5 col-md-4">
<q-btn
size="12px"
icon="arrow_downward"
rectangle
color="primary"
class="full-width"
@click="showReceiveTokensDialog"
>Receive Tokens</q-btn
>
</div>
<div class="col-2 col-sm-2 col-md-4"></div>
<div class="col-5 col-sm-5 col-md-4">
<q-btn
size="12px"
icon="arrow_upward"
rectangle
color="primary"
class="full-width"
@click="showSendTokensDialog"
>
Send Tokens</q-btn
>
</div>
</div>
<q-tabs v-model="tab" no-caps class="bg-dark text-white shadow-2">
<q-tab name="tokens" label="Tokens"></q-tab>
<q-tab name="invoices" label="Invoices"></q-tab>
<q-tab name="history" label="History"></q-tab>
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="tokens">
<q-table
dense
flat
:data="getTokenList()"
:columns="tokensTable.columns"
:pagination.sync="tokensTable.pagination"
no-data-label="No tokens yet"
:filter="tokensTable.filter"
>
{% raw %}
<template v-slot:body="props">
<q-tr :props="props">
<q-td
key="value"
:props="props"
:class="props.row.value > 0 ? 'text-green-13 text-weight-bold' : ''"
>
<div>{{props.row.value}}</div>
</q-td>
<q-td key="count" :props="props">
<div>{{props.row.count}}</div>
</q-td>
<q-td key="sum" :props="props">
<div>{{props.row.sum}}</div>
</q-td>
<q-td key="memo" :props="props">
<div>{{props.row.memo}}</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-tab-panel>
<q-tab-panel name="invoices">
<q-table
dense
flat
:data="invoicesCashu"
:columns="invoicesTable.columns"
:pagination.sync="invoicesTable.pagination"
no-data-label="There are no invoices here yet"
:filter="invoicesTable.filter"
>
{% raw %}
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="status" :props="props">
<div v-if="props.row.status == 'pending'">
<q-icon
@click="showInvoiceDialog(props.row)"
name="settings_ethernet"
color="grey"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
<q-badge
size="lg"
color="secondary"
class="q-mr-md cursor-pointer"
@click="recheckInvoice(props.row.hash)"
>
Check
</q-badge>
</div>
<div v-if="props.row.status === 'paid'">
<q-icon v-if="props.row.amount>0" name= "call_received" color="green"><q-tooltip>Received</q-tooltip></q-icon>
<q-icon v-if="props.row.amount<0" name= "call_made" color="red"><q-tooltip>Paid</q-tooltip></q-icon>
<!-- <q-icon name="props.row.amount < 0 ? 'call_made' : 'call_received'" color="green"></q-icon> -->
</div>
</q-td>
<q-td
key="amount"
:props="props"
:class="props.row.amount > 0 ? 'text-green-13 text-weight-bold' : ''"
>
<div>{{props.row.amount}}</div>
</q-td>
<q-td key="memo" :props="props">
<div>{{props.row.memo}}</div>
</q-td>
<q-td key="date" :props="props">
<div>{{props.row.date}}</div>
</q-td>
<q-td key="hash" :props="props">
<div>{{props.row.hash}}</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-tab-panel>
<q-tab-panel name="history">
<span>History</span>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
</q-card>
<div>
<q-btn
size="12px"
rectangle
color="warning"
outline
@click="showDisclaimerDialog"> Warning</q-btn>
</div>
</div>
<q-tabs
class="lt-md fixed-bottom q-pa-none left-0 right-0 bg-primary text-white shadow-2 z-top"
active-class="px-0"
align="justify"
indicator-color="transparent"
>
<q-tab
icon="arrow_downward"
label="Receive Invoice"
@click="showInvoicesDialog"
>
</q-tab>
</q-tab>
<q-tab icon="arrow_upward" label="Pay Invoice" @click="showParseDialog">
</q-tab>
<!-- <q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab> -->
</q-tabs>
<q-dialog v-model="payInvoiceData.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="payInvoiceData.invoice">
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
{% raw %} {{
parseFloat(String(payInvoiceData.invoice.fsat).replaceAll(",",
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
</h6>
<h6 v-else class="q-my-none">
{{ payInvoiceData.invoice.fsat }}{% endraw %}
{{LNBITS_DENOMINATION}} {% raw %}
</h6>
<q-separator class="q-my-sm"></q-separator>
<p class="text-wrap">
<strong>Description:</strong> {{
payInvoiceData.invoice.description }}<br />
<strong>Expire date:</strong> {{ payInvoiceData.invoice.expireDate
}}<br />
<strong>Hash:</strong> {{ payInvoiceData.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="primary" @click="melt">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="payInvoiceData.lnurlauth">
{% raw %}
<q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6">
Authenticate with <b>{{ payInvoiceData.lnurlauth.domain }}</b>?
</p>
<q-separator class="q-my-sm"></q-separator>
<p>
For every website and for every LNbits wallet, a new keypair
will be deterministically generated so your identity can't be
tied to your LNbits wallet or linked across websites. No other
data will be shared with {{ payInvoiceData.lnurlauth.domain }}.
</p>
<p>
Your public key for
<b>{{ payInvoiceData.lnurlauth.domain }}</b> is:
</p>
<p class="q-mx-xl">
<code class="text-wrap">
{{ payInvoiceData.lnurlauth.pubkey }}
</code>
</p>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Login</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-if="payInvoiceData.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="payInvoiceData.lnurlpay.fixed" class="q-my-none text-h6">
<b>{{ payInvoiceData.lnurlpay.domain }}</b> is requesting {{
payInvoiceData.lnurlpay.maxSendable | msatoshiFormat }}
{{LNBITS_DENOMINATION}}
<span v-if="payInvoiceData.lnurlpay.commentAllowed > 0">
<br />
and a {{payInvoiceData.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<p v-else class="q-my-none text-h6 text-center">
<b
>{{ payInvoiceData.lnurlpay.targetUser ||
payInvoiceData.lnurlpay.domain }}</b
>
is requesting <br />
between
<b
>{{ payInvoiceData.lnurlpay.minSendable | msatoshiFormat }}</b
>
and
<b
>{{ payInvoiceData.lnurlpay.maxSendable | msatoshiFormat }}</b
>
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
<span v-if="payInvoiceData.lnurlpay.commentAllowed > 0">
<br />
and a {{payInvoiceData.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<q-separator class="q-my-sm"></q-separator>
<div class="row">
<p class="col text-justify text-italic">
{{ payInvoiceData.lnurlpay.description }}
</p>
<p class="col-4 q-pl-md" v-if="payInvoiceData.lnurlpay.image">
<q-img :src="payInvoiceData.lnurlpay.image" />
</p>
</div>
<div class="row">
<div class="col">
{% endraw %}
<q-input
filled
dense
v-model.number="payInvoiceData.data.amount"
type="number"
label="Amount ({{LNBITS_DENOMINATION}}) *"
:min="payInvoiceData.lnurlpay.minSendable / 1000"
:max="payInvoiceData.lnurlpay.maxSendable / 1000"
:readonly="payInvoiceData.lnurlpay.fixed"
></q-input>
{% raw %}
</div>
<div
class="col-8 q-pl-md"
v-if="payInvoiceData.lnurlpay.commentAllowed > 0"
>
<q-input
filled
dense
v-model="payInvoiceData.data.comment"
:type="payInvoiceData.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="payInvoiceData.lnurlpay.commentAllowed"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
>Send {{LNBITS_DENOMINATION}}</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="!payInvoiceData.camera.show"
@submit="decodeRequest"
class="q-gutter-md"
>
<q-input
ref="pasteInput"
filled
dense
v-model.trim="payInvoiceData.data.request"
type="textarea"
label="Paste an invoice, payment request or lnurl code *"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="payInvoiceData.data.request == ''"
type="submit"
outline
>Continue</q-btn
>
<q-btn unelevated icon="photo_camera" label="Scan" class="q-ml-auto" @click="showCamera"> </q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
Cancel
</q-btn>
</div>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="payInvoiceData.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>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="disclaimerDialog.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">Warning</h6>
<p>
<strong>Bookmark this page!</strong>
</p>
<p>
Ecash is a bearer asset, meaning losing access to this wallet will mean you will
lose the funds. This wallet stores tokens in its database. If you lose the link or request
your data, you will lose your tokens. Bookmark this page or press the
hamburger icon and add this page to your home screen.
</p>
<p>
This service is in BETA, and we hold no responsibility for people losing
access to funds. Use at your own risk!
</p>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(disclaimerDialog.location.href)">Copy wallet URL</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>I understand</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showInvoiceDetails" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!invoiceData.bolt11">
<div class="row items-center no-wrap q-mb-sm">
<div class="col-12">
<span class="text-subtitle1">Create a Lightning invoice</span>
</div>
</div>
<q-input
filled
dense
v-model.number="invoiceData.amount"
label="Amount ({{LNBITS_DENOMINATION}}) *"
mask="#"
fill-mask="0"
reverse-fill-mask
autofocus
class="q-mb-lg"
@keyup.enter="requestMintButton"
></q-input>
<q-input
filled
dense
v-model.trim="invoiceData.memo"
label="Memo"
></q-input>
</div>
<div v-else class="text-center q-mb-lg">
<a :href="'lightning:' + invoiceData.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="invoiceData.bolt11"
:options="{width: 340}"
class="rounded-borders"
>
</qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="invoiceData.bolt11"
@click="copyText(invoiceData.bolt11)"
outline
color="primary"
>Copy invoice</q-btn
>
<q-btn v-else color="primary" @click="requestMintButton" :disable="!invoiceData.amount > 0"
>Create Invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showSendTokens" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!sendData.tokens">
<div class="row items-center no-wrap q-mb-sm">
<div class="col-12">
<span class="text-subtitle1"
>How much would you like to send?</span
>
</div>
</div>
<q-input
filled
dense
v-model.number="sendData.amount"
label="Amount ({{LNBITS_DENOMINATION}}) *"
mask="#"
fill-mask="0"
reverse-fill-mask
autofocus
class="q-mb-lg"
@keyup.enter="sendTokens"
></q-input>
<q-input
filled
dense
v-model.trim="sendData.memo"
label="Memo"
></q-input>
</div>
<div v-else class="text-center q-mb-lg">
<q-input
filled
dense
v-model="sendData.tokensBase64"
label="Tokens"
type="textarea"
class="q-mb-lg"
></q-input>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="!sendData.tokens"
:disable="sendData.amount == null || sendData.amount <= 0"
@click="sendTokens"
color="primary"
type="submit"
>Send Tokens</q-btn
>
<!-- <q-btn v-else @click="burnTokens" outline color="grey"
>Burn Tokens</q-btn
> -->
<q-btn
v-else
outline
color="primary"
@click="copyText(sendData.tokensBase64)"
>Copy token</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showReceiveTokens" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div>
<div class="row items-center no-wrap q-mb-sm">
<div class="col-12">
<span class="text-subtitle1">Receive Cashu tokens</span>
</div>
</div>
<q-input
filled
dense
v-model="receiveData.tokensBase64"
label="Paste encoded tokens *"
type="textarea"
autofocus
class="q-mb-lg"
></q-input>
</div>
<div class="row q-mt-lg">
<q-btn @click="redeem" outline color="grey"
>Receive Tokens</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</q-card>
</q-dialog>
</div>
</q-page>
</q-page-container>
{% endblock %} {% block styles %}
<style>
* {
touch-action: manipulation;
}
.keypad {
display: grid;
grid-gap: 8px;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
}
.keypad .btn {
height: 100%;
}
.keypad .btn-confirm {
grid-area: 1 / 4 / 5 / 4;
}
</style>
{% endblock %} {% block scripts %}
<script src="{{ url_for('cashu_static', path='js/noble-secp256k1.js') }}"></script>
<script src="{{ url_for('cashu_static', path='js/utils.js') }}"></script>
<script src="{{ url_for('cashu_static', path='js/dhke.js') }}"></script>
<script src="{{ url_for('cashu_static', path='js/base64.js') }}"></script>
<script>
var currentDateStr = function () {
return Quasar.utils.date.formatDate(new Date(), 'YYYY-MM-DD HH:mm')
}
var mapMint = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.cashu = ['/cashu/', obj.id].join('')
return obj
}
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
tickershort: '',
name: '',
mintId: '',
mintName: '',
keys: '',
invoicesCashu: [],
invoiceData: {
amount: 0,
memo: '',
bolt11: '',
hash: ''
},
invoiceCheckListener: () => {},
payInvoiceData: {
// invoice: '',
bolt11: '',
// camera: {
// show: false,
// camera: 'auto'
// }
show: false,
invoice: null,
lnurlpay: null,
lnurlauth: null,
data: {
request: '',
amount: 0,
comment: ''
},
paymentChecker: null,
camera: {
show: false,
camera: 'auto'
}
},
sendData: {
amount: 0,
memo: '',
tokens: '',
tokensBase64: ''
},
receiveData: {
tokensBase64: ''
},
showInvoiceDetails: false,
showPayInvoice: false,
showSendTokens: false,
showReceiveTokens: false,
promises: [],
tokens: [],
tab: 'tokens',
receive: {
show: false,
status: 'pending',
paymentReq: null,
paymentHash: null,
minMax: [0, 2100000000000000],
lnurl: null,
units: ['sat'],
unit: 'sat',
data: {
amount: null,
memo: ''
}
},
parse: {
show: false,
invoice: null,
lnurlpay: null,
lnurlauth: null,
data: {
request: '',
amount: 0,
comment: ''
},
paymentChecker: null,
camera: {
show: false,
camera: 'auto'
}
},
payments: [],
invoicesTable: {
columns: [
{
name: 'status',
align: 'left',
label: '',
field: 'status'
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount'
},
{
name: 'memo',
align: 'left',
label: 'Memo',
field: 'memo',
sortable: true
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
},
{
name: 'hash',
align: 'right',
label: 'Hash',
field: 'hash',
sortable: true
}
],
pagination: {
sortBy: 'date',
descending: true,
rowsPerPage: 5
},
filter: null
},
tokensTable: {
columns: [
{
name: 'value',
align: 'left',
label: 'Value ({{LNBITS_DENOMINATION}})',
field: 'value',
sortable: true
},
{
name: 'count',
align: 'left',
label: 'Count',
field: 'count',
sortable: true
},
{
name: 'sum',
align: 'left',
label: 'Sum ({{LNBITS_DENOMINATION}})',
field: 'sum',
sortable: true
}
// {
// name: 'memo',
// align: 'left',
// label: 'Memo',
// field: 'memo',
// sortable: true
// }
],
pagination: {
rowsPerPage: 5
},
filter: null
},
paymentsChart: {
show: false
},
disclaimerDialog: {
show: false,
location: window.location
},
credit: 0,
newName: ''
}
},
computed: {
formattedBalance: function () {
return this.balance / 100
},
canPay: function () {
if (!this.payInvoiceData.invoice) return false
return this.payInvoiceData.invoice.sat <= this.balance
},
pendingPaymentsExist: function () {
return this.payments.findIndex(payment => payment.pending) !== -1
},
balance: function () {
return this.proofs
.map(t => t)
.flat()
.reduce((sum, el) => (sum += el.amount), 0)
}
},
filters: {
msatoshiFormat: function (value) {
return LNbits.utils.formatSat(value / 1000)
}
},
methods: {
getBalance: function () {
return this.proofs
.map(t => t)
.flat()
.reduce((sum, el) => (sum += el.amount), 0)
},
getTokenList: function () {
const x = this.proofs
.map(t => t.amount)
.reduce((acc, amount) => {
acc[amount] = acc[amount] + amount || 1
return acc
}, {})
return Object.keys(x).map(k => ({
value: k,
count: x[k],
sum: k * x[k]
}))
},
paymentTableRowKey: function (row) {
return row.payment_hash + row.amount
},
closeCamera: function () {
this.payInvoiceData.camera.show = false
},
showCamera: function () {
this.payInvoiceData.camera.show = true
},
showChart: function () {
this.paymentsChart.show = true
this.$nextTick(() => {
generateChart(this.$refs.canvas, this.payments)
})
},
focusInput(el) {
this.$nextTick(() => this.$refs[el].focus())
},
showReceiveDialog: function () {
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = null
this.receive.data.memo = null
this.receive.unit = 'sat'
this.receive.paymentChecker = null
this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null
this.focusInput('setAmount')
},
showParseDialog: function () {
this.payInvoiceData.show = true
this.payInvoiceData.invoice = null
this.payInvoiceData.lnurlpay = null
this.payInvoiceData.lnurlauth = null
this.payInvoiceData.data.request = ''
this.payInvoiceData.data.comment = ''
this.payInvoiceData.data.paymentChecker = null
this.payInvoiceData.camera.show = false
this.focusInput('pasteInput')
},
showDisclaimerDialog: function() {
this.disclaimerDialog.show = true
},
closeReceiveDialog: function () {
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 10000)
},
closeParseDialog: function () {
setTimeout(() => {
clearInterval(this.payInvoiceData.paymentChecker)
}, 10000)
},
onPaymentReceived: function (paymentHash) {
this.fetchPayments()
this.fetchBalance()
if (this.receive.paymentHash === paymentHash) {
this.receive.show = false
this.receive.paymentHash = null
clearInterval(this.receive.paymentChecker)
}
},
createInvoice: function () {
this.receive.status = 'loading'
if (LNBITS_DENOMINATION != 'sats') {
this.receive.data.amount = this.receive.data.amount * 100
}
LNbits.api
.createInvoice(
this.receive.data.amount,
this.receive.data.memo,
this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback
)
.then(response => {
this.receive.status = 'success'
this.receive.paymentReq = response.data.payment_request
this.receive.paymentHash = response.data.payment_hash
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: 'warning',
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,
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true
})
}
}
clearInterval(this.receive.paymentChecker)
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 40000)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
this.receive.status = 'pending'
})
},
decodeQR: function (res) {
this.payInvoiceData.data.request = res
this.decodeRequest()
this.payInvoiceData.camera.show = false
},
decodeRequest: function () {
this.payInvoiceData.show = true
let req = this.payInvoiceData.data.request.toLowerCase()
if (
this.payInvoiceData.data.request
.toLowerCase()
.startsWith('lightning:')
) {
this.payInvoiceData.data.request = this.payInvoiceData.data.request.slice(
10
)
} else if (
this.payInvoiceData.data.request.toLowerCase().startsWith('lnurl:')
) {
this.payInvoiceData.data.request = this.payInvoiceData.data.request.slice(
6
)
} else if (req.indexOf('lightning=lnurl1') !== -1) {
this.payInvoiceData.data.request = this.payInvoiceData.data.request
.split('lightning=')[1]
.split('&')[0]
}
if (
this.payInvoiceData.data.request.toLowerCase().startsWith('lnurl1') ||
this.payInvoiceData.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
) {
return
}
let invoice
try {
invoice = decode(this.payInvoiceData.data.request)
} catch (error) {
this.$q.notify({
timeout: 3000,
type: 'warning',
message: error + '.',
caption: '400 BAD REQUEST'
})
this.payInvoiceData.show = false
throw error
return
}
let cleanInvoice = {
msat: invoice.human_readable_part.amount,
sat: invoice.human_readable_part.amount / 1000,
fsat: LNbits.utils.formatSat(
invoice.human_readable_part.amount / 1000
)
}
_.each(invoice.data.tags, tag => {
if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description === 'payment_hash') {
cleanInvoice.hash = tag.value
} else if (tag.description === 'description') {
cleanInvoice.description = tag.value
} else if (tag.description === 'expiry') {
var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000
)
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
cleanInvoice.expired = false // TODO
}
}
})
this.payInvoiceData.invoice = Object.freeze(cleanInvoice)
},
payInvoice: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...'
})
},
payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...'
})
},
authLnurl: function () {
let dismissAuthMsg = this.$q.notify({
timeout: 10,
message: 'Performing authentication...'
})
},
deleteWallet: function (walletId, user) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?')
.onOk(() => {
LNbits.href.deleteWallet(walletId, user)
})
},
fetchPayments: function () {
return
},
fetchBalance: function () {},
exportCSV: function () {
// status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons
// and would cause duplication in the list
let columns = this.paymentsTable.columns
columns.unshift({
name: 'pending',
align: 'left',
label: 'Pending',
field: 'pending'
})
LNbits.utils.exportCSV(columns, this.payments)
},
/////////////////////////////////// WALLET ///////////////////////////////////
showInvoicesDialog: async function () {
console.log('##### showInvoicesDialog')
this.invoiceData.amount = 0
this.invoiceData.bolt11 = ''
this.invoiceData.hash = ''
this.invoiceData.memo = ''
this.showInvoiceDetails = true
},
showInvoiceDialog: function (data) {
console.log('##### showInvoiceDialog')
this.invoiceData = _.clone(data)
this.showInvoiceDetails = true
},
showPayInvoiceDialog: function () {
console.log('### showPayInvoiceDialog')
this.payInvoiceData.invoice = ''
this.payInvoiceData.data.request = ''
this.showPayInvoice = true
this.payInvoiceData.camera.show = false
},
showSendTokensDialog: function () {
this.sendData.tokens = ''
this.sendData.tokensBase64 = ''
this.sendData.amount = 0
this.sendData.memo = ''
this.showSendTokens = true
},
showReceiveTokensDialog: function () {
this.receiveData.tokensBase64 = ''
this.showReceiveTokens = true
},
//////////////////////// MINT //////////////////////////////////////////
requestMintButton: async function () {
await this.requestMint()
console.log('this is your invoice BEFORE')
console.log(this.invoiceData)
this.invoiceCheckListener = setInterval(async () => {
try {
console.log('this is your invoice AFTER')
console.log(this.invoiceData)
await this.recheckInvoice(this.invoiceData.hash, false)
clearInterval(this.invoiceCheckListener)
this.invoiceData.bolt11 = ''
this.showInvoiceDetails = false
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Payment received'
})
} catch (error) {
console.log('not paid yet')
}
}, 3000)
},
requestMint: async function () {
// gets an invoice from the mint to get new tokens
try {
const {data} = await LNbits.api.request(
'GET',
`/cashu/api/v1/${this.mintId}/mint?amount=${this.invoiceData.amount}`
)
console.log('### data', data)
this.invoiceData.bolt11 = data.pr
this.invoiceData.hash = data.hash
this.invoicesCashu.push({
..._.clone(this.invoiceData),
date: currentDateStr(),
status: 'pending'
})
this.storeinvoicesCashu()
this.tab = 'invoices'
return data
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
mintApi: async function (amounts, payment_hash, verbose = true) {
console.log('### promises', payment_hash)
try {
let secrets = await this.generateSecrets(amounts)
let {blindedMessages, rs} = await this.constructOutputs(
amounts,
secrets
)
const promises = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/mint?payment_hash=${payment_hash}`,
'',
{
blinded_messages: blindedMessages
}
)
console.log('### promises data', promises.data)
let proofs = await this.constructProofs(promises.data, secrets, rs)
return proofs
} catch (error) {
console.error(error)
if (verbose) {
LNbits.utils.notifyApiError(error)
}
throw error
}
},
mint: async function (amount, payment_hash, verbose = true) {
try {
const split = splitAmount(amount)
const proofs = await this.mintApi(split, payment_hash, verbose)
if (!proofs.length) {
throw 'could not mint'
}
this.proofs = this.proofs.concat(proofs)
this.storeProofs()
await this.setInvoicePaid(payment_hash)
return proofs
} catch (error) {
console.error(error)
if (verbose) {
LNbits.utils.notifyApiError(error)
}
throw error
}
},
setInvoicePaid: async function (payment_hash) {
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
invoice.status = 'paid'
this.storeinvoicesCashu()
},
recheckInvoice: async function (payment_hash, verbose = true) {
console.log('### recheckInvoice.hash', payment_hash)
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
try {
proofs = await this.mint(invoice.amount, invoice.hash, verbose)
return proofs
} catch (error) {
console.log('Invoice still pending')
throw error
}
},
generateSecrets: async function (amounts) {
const secrets = []
for (let i = 0; i < amounts.length; i++) {
const secret = nobleSecp256k1.utils.randomBytes(32)
secrets.push(secret)
}
return secrets
},
constructOutputs: async function (amounts, secrets) {
const blindedMessages = []
const rs = []
for (let i = 0; i < amounts.length; i++) {
const {B_, r} = await step1Alice(secrets[i])
blindedMessages.push({amount: amounts[i], B_: B_})
rs.push(r)
}
return {
blindedMessages,
rs
}
},
constructProofs: function (promises, secrets, rs) {
const proofs = []
for (let i = 0; i < promises.length; i++) {
const encodedSecret = uint8ToBase64.encode(secrets[i])
let {id, amount, C, secret} = this.promiseToProof(
promises[i].id,
promises[i].amount,
promises[i]['C_'],
encodedSecret,
rs[i]
)
proofs.push({id, amount, C, secret})
}
return proofs
},
promiseToProof: function (id, amount, C_hex, secret, r) {
const C_ = nobleSecp256k1.Point.fromHex(C_hex)
const A = this.keys[amount]
const C = step3Alice(
C_,
nobleSecp256k1.utils.hexToBytes(r),
nobleSecp256k1.Point.fromHex(A)
)
return {
id,
amount,
C: C.toHex(true),
secret
}
},
sumProofs: function (proofs) {
return proofs.reduce((s, t) => (s += t.amount), 0)
},
splitToSend: async function (proofs, amount, invlalidate = false) {
try {
const spendableProofs = proofs.filter(p => !p.reserved)
if (this.sumProofs(spendableProofs) < amount) {
throw new Error('balance too low.')
}
let {fristProofs, scndProofs} = await this.split(
spendableProofs,
amount
)
// keep firstProofs, send scndProofs
// set scndProofs in this.proofs as reserved
const usedSecrets = proofs.map(p => p.secret)
for (let i = 0; i < this.proofs.length; i++) {
if (usedSecrets.includes(this.proofs[i].secret)) {
this.proofs[i].reserved = true
}
}
if (invlalidate) {
// delete tokens from db
this.proofs = fristProofs
// add new fristProofs, scndProofs to this.proofs
this.storeProofs()
}
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
split: async function (proofs, amount) {
try {
if (proofs.length == 0) {
throw new Error('no proofs provided.')
}
let {fristProofs, scndProofs} = await this.splitApi(proofs, amount)
// delete proofs from this.proofs
const usedSecrets = proofs.map(p => p.secret)
this.proofs = this.proofs.filter(p => !usedSecrets.includes(p.secret))
// add new fristProofs, scndProofs to this.proofs
this.proofs = this.proofs.concat(fristProofs).concat(scndProofs)
this.storeProofs()
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
splitApi: async function (proofs, amount) {
try {
const total = this.sumProofs(proofs)
const frst_amount = total - amount
const scnd_amount = amount
const frst_amounts = splitAmount(frst_amount)
const scnd_amounts = splitAmount(scnd_amount)
const amounts = _.clone(frst_amounts)
amounts.push(...scnd_amounts)
let secrets = await this.generateSecrets(amounts)
if (secrets.length != amounts.length) {
throw new Error(
'number of secrets does not match number of outputs.'
)
}
let {blindedMessages, rs} = await this.constructOutputs(
amounts,
secrets
)
const payload = {
amount,
proofs,
outputs: {
blinded_messages: blindedMessages
}
}
console.log('payload', JSON.stringify(payload))
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/split`,
'',
payload
)
const frst_rs = rs.slice(0, frst_amounts.length)
const frst_secrets = secrets.slice(0, frst_amounts.length)
const scnd_rs = rs.slice(frst_amounts.length)
const scnd_secrets = secrets.slice(frst_amounts.length)
const fristProofs = this.constructProofs(
data.fst,
frst_secrets,
frst_rs
)
const scndProofs = this.constructProofs(
data.snd,
scnd_secrets,
scnd_rs
)
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
redeem: async function () {
this.showReceiveTokens = false
console.log('### receive tokens', this.receiveData.tokensBase64)
try {
if (this.receiveData.tokensBase64.length == 0) {
throw new Error('no tokens provided.')
}
const tokensJson = atob(this.receiveData.tokensBase64)
const proofs = JSON.parse(tokensJson)
const amount = proofs.reduce((s, t) => (s += t.amount), 0)
let {fristProofs, scndProofs} = await this.split(proofs, amount)
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
// }
},
sendTokens: async function () {
// keep firstProofs, send scndProofs
let {fristProofs, scndProofs} = await this.splitToSend(
this.proofs,
this.sendData.amount,
true
)
this.sendData.tokens = ''
this.sendData.tokensBase64 = ''
this.sendData.tokens = scndProofs
console.log('### this.sendData.tokens', this.sendData.tokens)
this.sendData.tokensBase64 = btoa(JSON.stringify(this.sendData.tokens))
},
checkFees: async function (payment_request) {
const payload = {
pr: payment_request
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/checkfees`,
'',
payload
)
console.log('#### checkFees', payment_request, data.fee)
return data.fee
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
melt: async function () {
// todo: get fees from server and add to inputs
console.log('#### pay lightning')
const amount_invoice = this.payInvoiceData.invoice.sat
const amount =
amount_invoice +
(await this.checkFees(this.payInvoiceData.data.request))
console.log(
'#### amount invoice',
amount_invoice,
'amount with fees',
amount
)
// if (amount > balance()) {
// LNbits.utils.notifyApiError('Balance too low')
// return
// }
let {fristProofs, scndProofs} = await this.splitToSend(
this.proofs,
amount
)
const payload = {
proofs: scndProofs.flat(),
amount,
invoice: this.payInvoiceData.data.request
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/melt`,
'',
payload
)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Invoice paid'
})
// delete tokens from db
this.proofs = fristProofs
// add new fristProofs, scndProofs to this.proofs
this.storeProofs()
console.log({
amount: -amount,
bolt11: this.payInvoiceData.data.request,
hash: this.payInvoiceData.data.hash,
memo: this.payInvoiceData.data.memo,
})
this.invoicesCashu.push({
amount: -amount,
bolt11: this.payInvoiceData.data.request,
hash: this.payInvoiceData.data.hash,
memo: this.payInvoiceData.data.memo,
date: currentDateStr(),
status: 'paid'
})
this.storeinvoicesCashu()
this.tab = 'invoices'
this.payInvoiceData.invoice = false
this.payInvoiceData.show = false
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
recheckPendingInvoices: async function () {
for (const invoice of this.invoicesCashu) {
if (invoice.status === 'pending' && invoice.sat > 0) {
this.recheckInvoice(invoice.hash, false)
}
}
},
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
findTokenForAmount: function (amount) {
for (const token of this.proofs) {
const index = token.promises?.findIndex(p => p.amount === amount)
if (index >= 0) {
return {
promise: token.promises[index],
secret: token.secrets[index],
r: token.rs[index]
}
}
}
},
checkInvoice: function () {
console.log('#### checkInvoice')
try {
const invoice = decode(this.payInvoiceData.data.request)
const cleanInvoice = {
msat: invoice.human_readable_part.amount,
sat: invoice.human_readable_part.amount / 1000,
fsat: LNbits.utils.formatSat(
invoice.human_readable_part.amount / 1000
)
}
_.each(invoice.data.tags, tag => {
if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description === 'payment_hash') {
cleanInvoice.hash = tag.value
} else if (tag.description === 'description') {
cleanInvoice.description = tag.value
} else if (tag.description === 'expiry') {
var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000
)
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
cleanInvoice.expired = false // TODO
}
}
this.payInvoiceData.invoice = cleanInvoice
})
console.log(
'#### this.payInvoiceData.invoice',
this.payInvoiceData.invoice
)
} catch (error) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Cannot decode invoice',
caption: error + ''
})
throw error
}
},
fetchMintKeys: async function () {
const {data} = await LNbits.api.request(
'GET',
`/cashu/api/v1/${this.mintId}/keys`
)
this.keys = data
localStorage.setItem('cashu.keys', JSON.stringify(data))
},
storeinvoicesCashu: function () {
localStorage.setItem(
'cashu.invoicesCashu',
JSON.stringify(this.invoicesCashu)
)
},
storeProofs: function () {
localStorage.setItem(
'cashu.proofs',
JSON.stringify(this.proofs, bigIntStringify)
)
}
},
watch: {
payments: function () {
this.balance()
}
},
created: function () {
let params = new URL(document.location).searchParams
// get ticker
if (
!params.get('tsh') &&
!this.$q.localStorage.getItem('cashu.tickershort')
) {
this.$q.localStorage.set('cashu.tickershort', 'sats')
this.tickershort = 'sats'
} else if (params.get('tsh')) {
this.$q.localStorage.set('cashu.tickershort', params.get('tsh'))
this.tickershort = params.get('tsh')
} else if (this.$q.localStorage.getItem('cashu.tickershort')) {
this.tickershort = this.$q.localStorage.getItem('cashu.tickershort')
}
// get mint
if (params.get('mint_id')) {
this.mintId = params.get('mint_id')
this.$q.localStorage.set('cashu.mint', params.get('mint_id'))
} else if (this.$q.localStorage.getItem('cashu.mint')) {
this.mintId = this.$q.localStorage.getItem('cashu.mint')
} else {
this.$q.notify({
color: 'red',
message: 'No mint set!'
})
}
// get name
if (params.get('mint_name')) {
this.mintName = params.get('mint_name')
this.$q.localStorage.set('cashu.mintName', params.get('mint_name'))
} else if (this.$q.localStorage.getItem('cashu.name')) {
this.mintName = this.$q.localStorage.getItem('cashu.name')
}
const keysJson = localStorage.getItem('cashu.keys')
if (!keysJson) {
this.fetchMintKeys()
} else {
this.keys = JSON.parse(keysJson)
}
this.invoicesCashu = JSON.parse(
localStorage.getItem('cashu.invoicesCashu') || '[]'
)
this.proofs = JSON.parse(localStorage.getItem('cashu.proofs') || '[]')
console.log('### invoicesCashu', this.invoicesCashu)
console.table('### tokens', this.proofs)
console.log('#### this.mintId', this.mintId)
console.log('#### this.mintName', this.mintName)
this.recheckPendingInvoices()
}
})
</script>
{% endblock %}