Files
lnbits/templates/cashu/wallet2.html
T
2023-02-13 10:53:29 +01:00

2623 lines
84 KiB
HTML

{% extends "public.html" %} {% block toolbar_title %} {% raw %} Cashu {% endraw
%} - {{mint_name}} {% endblock %} {% block footer %}{% endblock %} {% block
page_container %}
<q-page-container>
<q-page>
<div class="row q-col-gutter-md justify-center q-pt-sm q-pb-md">
<div class="col-12 col-sm-11 col-md-8 text-center q-gutter-y-md">
<q-card class="q-my-md q-py-sm">
<q-card-section class="q-my-sm q-py-xs">
<div class="gt-sm">
<div class="row">
<div class="col-3 q-pt-sm">
<q-btn
class="full-width"
size="14px"
icon="bolt"
rectangle
color="primary"
@click="showInvoiceCreateDialog"
><strong>Create invoice</strong>
</q-btn>
</div>
<div class="col-6">
<h3 class="q-my-none q-py-none">
<center>
<strong>{% raw %} {{ getBalance() }} </strong
>{{tickerShort}} {% endraw %}
</center>
</h3>
</div>
<div class="col-3 q-pt-sm">
<q-btn
class="full-width"
@click="showParseDialog"
size="14px"
icon="bolt"
rectangle
color="primary"
class="full-width"
><strong>Pay invoice</strong>
</q-btn>
</div>
</div>
</div>
<div class="lt-md">
<div class="row">
<div class="col-12">
<h3 class="q-my-none q-py-none">
<center>
<strong>{% raw %} {{ getBalance() }} </strong>
{{tickerShort}} {% endraw %}
</center>
</h3>
</div>
</div>
</div>
</q-card-section>
</q-card>
<q-card class="q-mt-xs">
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col-6 col-sm-5 col-md-4 q-px-xs">
<q-btn
size="12px"
rectangle
unelevated
color="primary"
icon="file_download"
class="full-width"
@click="showReceiveTokensDialog"
>Get Ecash</q-btn
>
</div>
<div class="col-0 col-sm-2 col-md-4"></div>
<div class="col-6 col-sm-5 col-md-4 q-px-xs">
<q-btn
size="12px"
rectangle
unelevated
color="primary"
icon="file_upload"
class="full-width"
@click="showSendTokensDialog"
>
Pay Ecash</q-btn
>
</div>
</div>
<!-- ///////////////////////////////////////////
////////////////// TABLES /////////////////
/////////////////////////////////////////// -->
<q-tabs v-model="tab" no-caps class="bg-dark text-white shadow-2">
<q-tab name="invoices" label="Invoices"></q-tab>
<q-tab name="tokens" label="Tokens"></q-tab>
<q-tab name="history" label="History"></q-tab>
<q-tab name="settings" label="Settings"></q-tab>
</q-tabs>
<q-tab-panels v-model="tab">
<!-- ////////////////////// SETTINGS ////////////////// -->
<q-tab-panel name="settings">
<div class="q-pa-md bg-grey-10 text-white text-left" on-left>
<q-list padding>
<q-item>
<q-item-section>
<q-item-label overline>Cashu mints</q-item-label>
<q-item-label>{{item}}</q-item-label>
<q-item-label caption
>You have to connect your wallet to one or more
mints.</q-item-label
>
</q-item-section>
</q-item>
<q-item-label header>Your mints</q-item-label>
<div v-for="(mint, index) in mints" :key="mint.url">
{% raw %}
<q-item>
<q-item-section avatar>
<q-icon
:color="mint.url == activeMintURL ? 'primary' : 'grey'"
name="link"
@click="activateMint(mint.url, verbose=true)"
/>
</q-item-section>
<q-item-section> {{mint.url}}</q-item-section>
<q-item-section side>
<q-item-label caption
>{{mint.balance}} {{tickerShort}}</q-item-label
>
</q-item-section>
<q-item-section side>
<q-icon name="cancel" @click="removeMint(mint.url)" />
</q-item-section>
</q-item>
{% endraw %}
<q-separator spaced inset="item" />
</div>
</q-list>
</div>
<div class="q-gutter-md q-pt-md">
<div class="row-12">
<q-input
standout
bottom-slots
v-model="mintToAdd"
label="Mint URL"
>
<template v-slot:before>
<q-icon name="account_balance" />
</template>
<!-- <template v-slot:hint> Enter Mint URL</template> -->
<template v-slot:append>
<q-btn
round
dense
flat
icon="add"
click
@click="addMint(mintToAdd)"
/>
</template>
</q-input>
</div>
</div>
</q-tab-panel>
<!-- ////////////////// TOKEN LIST ///////////////// -->
<q-tab-panel name="tokens">
<q-table
dense
flat
:data="getTokenList()"
:columns="tokensTable.columns"
:pagination.sync="tokensTable.pagination"
no-data-label="There are no tokens here 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-tr>
</template>
{% endraw %}
</q-table>
</q-tab-panel>
<!-- ////////////////// INVOICE LIST ///////////////// -->
<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="showInvoicInfoDialog(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="checkInvoice(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 && props.row.status === 'paid' ? 'text-green-13 text-weight-bold' : ''"
>
<div>{{props.row.amount}}</div>
</q-td>
<q-td key="date" :props="props">
<div>{{props.row.date}}</div>
</q-td>
<!-- <q-td key="memo" :props="props">
<div>{{props.row.memo}}</div>
</q-td> -->
<q-td key="bolt11" :props="props">
<div>{{props.row.bolt11}}</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>
<!-- ////////////////// HISTORY LIST ///////////////// -->
<q-tab-panel name="history">
<q-table
dense
flat
:data="historyTokens"
:columns="historyTable.columns"
:pagination.sync="historyTable.pagination"
no-data-label="There are no tokens here yet"
:filter="historyTable.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="showTokenDialog(props.row.token)"
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="checkTokenSpendable(props.row.token)"
>
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="date" :props="props">
<div>{{props.row.date}}</div>
</q-td>
<!-- <q-td key="memo" :props="props">
<div>{{props.row.memo}}</div>
</q-td> -->
<q-td key="token" :props="props">
<div>{{props.row.token}}</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
</q-card>
<div class="q-pa-sd q-pt-xs" style="margin-bottom: 5rem" align="center">
<q-btn
class="q-mx-sm"
size="10px"
rectangle
color="warning"
outline
@click="showDisclaimerDialog"
>
Warning</q-btn
>
<q-btn
class="q-mx-sm"
size="10px"
outline
rectangle
color="warning"
@click="getLocalstorageToFile"
>Backup<q-tooltip>Download wallet backup</q-tooltip></q-btn
>
</div>
</div>
<div class="q-col-gutter" style="max-width: 600px">
<q-tabs
class="lt-md fixed-bottom q-px-none q-py-md left-0 right-0 bg-primary text-white shadow-2 z-top q-px-0"
indicator-color="transparent"
align="justify"
>
<q-tab
class="col-5"
label="Create Invoice"
icon="bolt"
@click="showInvoiceCreateDialog"
>
</q-tab>
<q-tab
class="col-2"
icon="photo_camera"
v-if="hasCamera"
@click="showCamera"
>
</q-tab>
<q-tab
class="col-5"
icon="bolt"
@click="showParseDialog"
label="Pay Invoice"
>
</q-tab>
</q-tabs>
</div>
<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"
:disabled="payInvoiceData.blocking"
@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="!camera.show"
@submit="decodeRequest"
class="q-gutter-md"
>
<q-input
ref="pasteInput"
filled
dense
v-model.trim="payInvoiceData.data.request"
type="textarea"
label="Enter a Lightning invoice *"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="payInvoiceData.data.request == ''"
type="submit"
>Enter</q-btn
>
<q-btn
unelevated
icon="photo_camera"
class="q-mx-0"
v-if="hasCamera"
@click="showCamera"
>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</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="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 and backup your tokens!</strong>
Ecash is a bearer asset, meaning losing access to this wallet will
mean you will lose the funds. This wallet stores ecash tokens in its
database. If you lose the link or delete your your data without
backing up, you will lose your tokens. Press the Backup button to
download a copy of your tokens.
</p>
<p>
<strong>Add to home screen.</strong>
You can add Cashu to your home screen as a progressive web app
(PWA). On Android Chrome, click the hamburger menu at the upper
right. On iOS Safari, click the share button. Now press the Add to
Home screen button.
</p>
<p>
<strong>This service is in BETA!</strong> 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
type="number"
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 class="text-secondary" :href="'lightning:' + invoiceData.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="'lightning:' + invoiceData.bolt11.toUpperCase()"
: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
type="number"
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">
<div class="text-center q-mb-lg">
<!-- <a class="text-secondary" :href="'cashu:' + sendData.tokensBase64"> -->
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="walletURL + '&recv_token=' + sendData.tokensBase64"
:options="{width: 340}"
class="rounded-borders"
>
</qrcode>
</q-responsive>
<!-- </a> -->
</div>
<q-input
outlined
dense
readonly
v-model="sendData.tokensBase64"
label="Token"
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
> -->
<div v-else>
<q-btn color="primary" @click="copyText(sendData.tokensBase64)"
>Copy token</q-btn
>
<q-btn
color="primary"
@click="copyText(walletURL + '&recv_token=' + sendData.tokensBase64)"
>Copy link</q-btn
>
</div>
<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" color="primary">Receive</q-btn>
<q-btn
unelevated
icon="content_copy"
class="q-mx-0"
@click="copyText(receiveData.tokensBase64)"
></q-btn>
<q-btn
unelevated
icon="photo_camera"
class="q-mx-0"
v-if="hasCamera"
@click="showCamera"
></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:ss')
}
var mapMint = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm:ss'
)
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: 'sats',
ticketLong: 'Satoshis',
name: '',
mintId: '',
mintName: '',
mintToAdd: 'https://8333.space:3338',
activeMintURL: '',
mints: [],
keys: '',
proofs: [],
activeProofs: [],
invoicesCashu: [],
historyTokens: [],
invoiceData: {
amount: 0,
memo: '',
bolt11: '',
hash: ''
},
camera: {
data: null,
show: false,
camera: 'auto'
},
invoiceCheckListener: () => {},
payInvoiceData: {
blocking: false,
bolt11: '',
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: 'invoices',
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: [],
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
}
],
pagination: {
rowsPerPage: 5
},
filter: null
},
invoicesTable: {
columns: [
{
name: 'status',
align: 'left',
label: '',
field: 'status',
sortable: true
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount',
sortable: true
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
},
// {
// name: 'memo',
// align: 'left',
// label: 'Memo',
// field: 'memo',
// sortable: true
// },
{
name: 'bolt11',
align: 'left',
label: 'Payment request',
field: 'bolt11',
sortable: false
},
{
name: 'hash',
align: 'left',
label: 'Hash',
field: 'hash',
sortable: false
}
],
pagination: {
sortBy: 'date',
descending: true,
rowsPerPage: 5
},
filter: null
},
historyTable: {
columns: [
{
name: 'status',
align: 'left',
label: '',
field: 'status',
sortable: true
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount',
sortable: true
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
},
// {
// name: 'memo',
// align: 'left',
// label: 'Memo',
// field: 'memo',
// sortable: true
// },
{
name: 'token',
align: 'left',
label: 'Token',
field: 'token',
sortable: false
}
],
pagination: {
sortBy: 'date',
descending: true,
rowsPerPage: 5
},
filter: null
},
paymentsChart: {
show: false
},
disclaimerDialog: {
show: false,
location: window.location,
base_url: location.protocol + '//' + location.host + location.pathname
},
baseURL: location.protocol + '//' + location.host + location.pathname,
credit: 0,
newName: ''
}
},
computed: {
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.activeProofs
.map(t => t)
.flat()
.reduce((sum, el) => (sum += el.amount), 0)
}
},
filters: {
msatoshiFormat: function (value) {
return LNbits.utils.formatSat(value / 1000)
}
},
methods: {
addMint: function (url) {
var verbose = false
if (this.mints.filter(m => m.url == url).length == 0) {
this.mints.push({url: url})
this.$q.localStorage.set('cashu.mints', this.mints)
verbose = true
}
this.activateMint(url, (verbose = verbose))
},
removeMint: function (url) {
this.mints = this.mints.filter(m => m.url != url)
this.$q.localStorage.set('cashu.mints', this.mints)
this.activateMint(this.mints[0].url)
this.notifySuccess('Mint removed.')
},
getBalance: function () {
var balance = this.activeProofs
.map(t => t)
.flat()
.reduce((sum, el) => (sum += el.amount), 0)
this.mints.filter(m => m.url == this.activeMintURL)[0].balance = balance
return balance
},
activateMint: async function (url, verbose = false) {
let presiouvURL = this.activeMintURL
try {
this.activeMintURL = url
keys = await this.fetchMintKeys()
// load proofs
this.activeProofs = this.proofs.filter(p =>
this.keysets.includes(p.id)
)
if (verbose) {
this.notifySuccess('Connected to mint.')
}
} catch (error) {
this.activeMintURL = presiouvURL
this.notifyError('Could not connect to mint.')
throw error
}
},
getTokenList: function () {
const amounts = this.activeProofs.map(t => t.amount)
const counts = {}
for (const num of amounts) {
counts[num] = counts[num] ? counts[num] + 1 : 1
}
return Object.keys(counts).map(k => ({
value: parseInt(k),
count: parseInt(counts[k]),
sum: k * counts[k]
}))
},
paymentTableRowKey: function (row) {
return row.payment_hash + row.amount
},
closeCamera: function () {
this.camera.show = false
},
showCamera: function () {
this.camera.show = true
},
hasCamera: function () {
navigator.permissions.query({name: 'camera'}).then(res => {
return res.state == 'granted'
})
},
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.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,
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
return
} else if (response.data.lnurl_response === true) {
// success
this.$q.notify({
timeout: 5000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true,
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
}
}
clearInterval(this.receive.paymentChecker)
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 40000)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
this.receive.status = 'pending'
})
},
decodeQR: function (res) {
this.camera.data = res
// this.payInvoiceData.data.request = res
this.decodeRequest()
this.camera.show = false
},
decodeRequest: function () {
let reqtype = null
let req = null
// get request
if (this.camera.data) {
// get request from camera
req = this.camera.data
} else if (this.payInvoiceData.data.request) {
// get request from pay invoice dialog
req = this.payInvoiceData.data.request
}
if (req.toLowerCase().startsWith('lnbc')) {
this.payInvoiceData.data.request = req
reqtype = 'bolt11'
} else if (req.toLowerCase().startsWith('lightning:')) {
this.payInvoiceData.data.request = req.slice(10)
reqtype = 'bolt11'
} else if (req.toLowerCase().startsWith('lnurl:')) {
this.payInvoiceData.data.request = req.slice(6)
reqtype = 'lnurl'
} else if (req.indexOf('lightning=lnurl1') !== -1) {
this.payInvoiceData.data.request = req
.split('lightning=')[1]
.split('&')[0]
reqtype = 'lnurl'
} else if (
req.toLowerCase().startsWith('lnurl1') ||
req.match(/[\w.+-~_]+@[\w.+-~_]/)
) {
this.payInvoiceData.data.request = req
reqtype = 'lnurl'
return
// } else if (req.indexOf('cashu:') !== 1) {
// this.receiveData.tokensBase64 = req.slice(req.indexOf('cashu:'))
// reqtype = 'cashu'
} else if (req.indexOf('W3siaWQ') !== 1) {
// very dirty way of parsing cashu tokens
this.receiveData.tokensBase64 = req.slice(req.indexOf('W3siaWQ'))
reqtype = 'cashu'
}
if (reqtype == 'bolt11') {
console.log('#### QR CODE: BOLT11')
this.payInvoiceData.show = true
let invoice
try {
invoice = decode(this.payInvoiceData.data.request)
} catch (error) {
this.$q.notify({
timeout: 3000,
type: 'warning',
message: error + '.',
caption: 'Failed to decode invoice',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
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)
} else if (reqtype == 'lnurl') {
console.log('#### QR CODE: LNURL')
// not supported yet
} else if (reqtype == 'cashu') {
console.log('#### QR CODE: CASHU TOKEN')
this.showReceiveTokens = true
}
},
payInvoice: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
},
payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
},
authLnurl: function () {
let dismissAuthMsg = this.$q.notify({
timeout: 10,
message: 'Performing authentication...',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
},
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 ///////////////////////////////////
showInvoiceCreateDialog: async function () {
console.log('##### showInvoiceCreateDialog')
this.invoiceData.amount = ''
this.invoiceData.bolt11 = ''
this.invoiceData.hash = ''
this.invoiceData.memo = ''
this.showInvoiceDetails = true
},
showInvoicInfoDialog: function (data) {
console.log('##### showInvoicInfoDialog')
this.invoiceData = _.clone(data)
this.showInvoiceDetails = true
// kick off invoice check worker
this.invoiceCheckWorker()
},
// showPayInvoiceDialog: function () {
// console.log('### showPayInvoiceDialog')
// this.payInvoiceData.invoice = ''
// this.payInvoiceData.data.request = ''
// this.showPayInvoice = true
// this.camera.show = false
// },
showTokenDialog: function (token) {
console.log('##### showTokenDialog')
// TODO: this must be decoded and desiarlized!
this.sendData.tokens = _.clone(token)
this.sendData.tokensBase64 = _.clone(token)
this.showSendTokens = true
// kick off token check worker
this.checkTokenSpendableWorker()
},
showSendTokensDialog: function () {
console.log('##### showSendTokensDialog')
this.sendData.tokens = ''
this.sendData.tokensBase64 = ''
this.sendData.amount = ''
this.sendData.memo = ''
this.showSendTokens = true
},
showReceiveTokensDialog: function () {
this.receiveData.tokensBase64 = ''
this.showReceiveTokens = true
},
//////////////////////// MINT //////////////////////////////////////////
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 outputs = []
const rs = []
for (let i = 0; i < amounts.length; i++) {
const {B_, r} = await step1Alice(secrets[i])
outputs.push({amount: amounts[i], B_: B_})
rs.push(r)
}
return {
outputs,
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)
},
deleteProofs: function (proofs) {
// delete proofs from this.proofs
const usedSecrets = proofs.map(p => p.secret)
this.proofs = this.proofs.filter(p => !usedSecrets.includes(p.secret))
this.storeProofs()
return this.proofs
},
serializeProofs: function (proofs) {
// unique keyset IDs of proofs
var uniqueIds = [...new Set(proofs.map(p => p.id))]
// mints that have any of the keyset IDs
var mints_keysets = this.mints.filter(m =>
m.keysets.some(r => uniqueIds.indexOf(r) >= 0)
)
// what we put into the JSON
var mints = mints_keysets.map(m => [{url: m.url, ids: m.keysets}][0])
var token = {
proofs: proofs,
mints
}
return btoa(JSON.stringify(token))
},
//////////// API ///////////
// MINT
requestMintButton: async function () {
await this.requestMint()
console.log('#### request mint', this.invoiceData)
await this.invoiceCheckWorker()
},
// /mint
requestMint: async function () {
/*
gets an invoice from the mint to get new tokens
*/
try {
const {data} = await axios.get(
`${this.activeMintURL}/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
}
},
// /mint
mintApi: async function (amounts, payment_hash, verbose = true) {
/*
asks the mint to check whether the invoice with payment_hash has been paid
and requests signing of the attached outputs.
*/
try {
let secrets = await this.generateSecrets(amounts)
let {outputs, rs} = await this.constructOutputs(amounts, secrets)
const promises = await axios.post(
`${this.activeMintURL}/mint?payment_hash=${payment_hash}`,
{
outputs
}
)
this.assertMintError(promises.data)
if (promises.data.promises == null) {
return {}
}
let proofs = await this.constructProofs(
promises.data.promises,
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)
// hack to update balance
this.activeProofs = this.activeProofs.concat([])
this.storeProofs()
// update UI
await this.setInvoicePaid(payment_hash)
tokensBase64 = btoa(JSON.stringify(proofs))
this.historyTokens.push({
status: 'paid',
amount: amount,
date: currentDateStr(),
token: tokensBase64
})
this.storehistoryTokens()
return proofs
} catch (error) {
console.error(error)
if (verbose) {
LNbits.utils.notifyApiError(error)
}
throw error
}
},
// SPLIT
split: async function (proofs, amount) {
/*
supplies proofs and requests a split from the mint of these
proofs at a specific amount
*/
try {
if (proofs.length == 0) {
throw new Error('no proofs provided.')
}
let {fristProofs, scndProofs} = await this.splitApi(proofs, amount)
this.deleteProofs(proofs)
// 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
}
},
// /split
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 {outputs, rs} = await this.constructOutputs(amounts, secrets)
const payload = {
amount,
proofs,
outputs
}
const {data} = await axios.post(
`${this.activeMintURL}/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) {
this.payInvoiceData.blocking = false
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
splitToSend: async function (proofs, amount, invlalidate = false) {
/*
splits proofs so the user can keep firstProofs, send scndProofs.
then sets scndProofs as reserved.
if invalidate, scndProofs (the one to send) are invalidated
*/
try {
const spendableProofs = proofs.filter(p => !p.reserved)
if (this.sumProofs(spendableProofs) < amount) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Balance too low',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
throw Error('balance too low.')
}
// call /split
let {fristProofs, scndProofs} = await this.split(
spendableProofs,
amount
)
// 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
}
}
// hack: to make Vue JS update
this.proofs = this.proofs.concat([])
if (invlalidate) {
// delete scndProofs from db
this.deleteProofs(scndProofs)
}
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
redeem: async function () {
/*
uses split to receive new tokens.
*/
this.showReceiveTokens = false
console.log('### receive tokens', this.receiveData.tokensBase64)
try {
if (this.receiveData.tokensBase64.length == 0) {
throw new Error('no tokens provided.')
}
const tokenJson = JSON.parse(atob(this.receiveData.tokensBase64))
const proofs = tokenJson.proofs
const amount = proofs.reduce((s, t) => (s += t.amount), 0)
// check if we have all mints
for (var i = 0; i < tokenJson.mints.length; i++) {
if (!this.mints.map(m => m.url).includes(tokenJson.mints[i].url)) {
this.addMint(tokenJson.mints[i].url)
}
}
// redeem
let {fristProofs, scndProofs} = await this.split(proofs, amount)
// update UI
// HACK: we need to do this so the balance updates
this.proofs = this.proofs.concat([])
this.activeProofs = this.activeProofs.concat([])
this.getBalance()
this.historyTokens.push({
status: 'paid',
amount: amount,
date: currentDateStr(),
token: this.receiveData.tokensBase64
})
this.storehistoryTokens()
if (window.navigator.vibrate) navigator.vibrate(200)
this.notifySuccess('Tokens received.')
// this.$q.notify({
// timeout: 5000,
// type: 'positive',
// message: 'Tokens received',
// position: 'top',
// actions: [
// {
// icon: 'close',
// color: 'white',
// handler: () => {}
// }
// ]
// })
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
// }
},
sendTokens: async function () {
/*
calls splitToSend, displays token and kicks off the spendableWorker
*/
try {
// keep firstProofs, send scndProofs and delete them (invalidate=true)
let {fristProofs, scndProofs} = await this.splitToSend(
this.activeProofs,
this.sendData.amount,
true
)
// update UI
this.sendData.tokens = scndProofs
console.log('### this.sendData.tokens', this.sendData.tokens)
this.sendData.tokensBase64 = this.serializeProofs(scndProofs)
this.historyTokens.push({
status: 'pending',
amount: -this.sendData.amount,
date: currentDateStr(),
token: this.sendData.tokensBase64
})
// store "pending" outgoing tokens in history table
this.storehistoryTokens()
this.checkTokenSpendableWorker()
} catch (error) {
console.error(error)
throw error
}
},
// /melt
melt: async function () {
// todo: get fees from server and add to inputs
this.payInvoiceData.blocking = true
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
)
let {fristProofs, scndProofs} = await this.splitToSend(
this.activeProofs,
amount
)
const payload = {
proofs: scndProofs.flat(),
amount,
pr: this.payInvoiceData.data.request
}
try {
const {data} = await axios.post(`${this.activeMintURL}/melt`, payload)
if (window.navigator.vibrate) navigator.vibrate(200)
this.notifySuccess('Token paid.')
// this.$q.notify({
// timeout: 5000,
// type: 'positive',
// message: 'Invoice paid',
// position: 'top',
// actions: [
// {
// icon: 'close',
// color: 'white',
// handler: () => {}
// }
// ]
// })
// delete spent tokens from db
this.deleteProofs(scndProofs)
// update UI
tokensBase64 = btoa(JSON.stringify(scndProofs))
this.historyTokens.push({
status: 'paid',
amount: -amount,
date: currentDateStr(),
token: tokensBase64
})
this.storehistoryTokens()
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
this.payInvoiceData.blocking = false
} catch (error) {
this.payInvoiceData.blocking = false
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
// /check
checkProofsSpendable: async function (proofs, update_history = false) {
/*
checks with the mint whether an array of proofs is still
spendable or already invalidated
*/
if (proofs.length == 0) {
return
}
const payload = {
proofs: proofs.map(p => {
return {secret: p.secret}
})
}
try {
const {data} = await axios.post(
`${this.activeMintURL}/check`,
payload
)
// delete proofs from database if it is spent
let spentProofs = proofs.filter((p, pidx) => !data.spendable[pidx])
if (spentProofs.length) {
this.deleteProofs(spentProofs)
// update UI
if (update_history) {
tokensBase64 = btoa(JSON.stringify(spentProofs))
this.historyTokens.push({
status: 'paid',
amount: -this.sumProofs(spentProofs),
date: currentDateStr(),
token: tokensBase64
})
this.storehistoryTokens()
}
}
return data.spendable
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
// /checkfees
checkFees: async function (payment_request) {
const payload = {
pr: payment_request
}
try {
const {data} = await axios.post(
`${this.activeMintURL}/checkfees`,
payload
)
console.log('#### checkFees', payment_request, data.fee)
return data.fee
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
// /keys
fetchMintKeys: async function () {
try {
const {data} = await axios.get(`${this.activeMintURL}/keys`, {
timeout: 2000
})
var keys = data
this.assertMintError(keys)
this.keys = keys
localStorage.setItem('cashu.keys', JSON.stringify(keys))
keysets = await this.fetchMintKeysets()
// save keys to mints in local storage
if (this.mints.filter(m => m.url == this.activeMintURL).length) {
this.mints.filter(m => m.url == this.activeMintURL)[0].keys = keys
this.mints.filter(m => m.url == this.activeMintURL)[0].keysets =
keysets
this.$q.localStorage.set('cashu.activeMintURL', this.activeMintURL)
this.$q.localStorage.set('cashu.mints', this.mints)
}
return keys
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
// /keysets
fetchMintKeysets: async function () {
try {
const {data} = await axios.get(`${this.activeMintURL}/keysets`, {
timeout: 2000
})
this.assertMintError(data)
this.keysets = data.keysets
localStorage.setItem('cashu.keysets', JSON.stringify(data.keysets))
return data.keysets
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
////////////// UI HELPERS //////////////
setInvoicePaid: async function (payment_hash) {
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
invoice.status = 'paid'
this.storeinvoicesCashu()
},
checkInvoice: async function (payment_hash, verbose = true) {
console.log('### checkInvoice.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
}
},
checkPendingInvoices: async function () {
for (const invoice of this.invoicesCashu) {
if (invoice.status === 'pending' && invoice.amount > 0) {
try {
await this.checkInvoice(invoice.hash, false)
} catch (error) {
console.log(`${invoice.hash} still pending`)
throw error
}
}
}
},
checkPendingTokens: async function () {
for (const token of this.historyTokens) {
if (token.status === 'pending' && token.amount < 0) {
this.checkTokenSpendable(token.token, false)
}
}
},
setTokenPaid: async function (token) {
const invoice = this.historyTokens.find(i => i.token === token)
invoice.status = 'paid'
this.storehistoryTokens()
},
checkTokenSpendable: async function (token, verbose = true) {
/*
checks whether a base64-encoded token (from the history table) has been spent already.
if it is spent, the appropraite entry in the history table is set to paid.
*/
const tokenJson = atob(token)
const proofs = JSON.parse(tokenJson)
const spendable = await this.checkProofsSpendable(proofs)
let paid = false
if (spendable.includes(false)) {
this.setTokenPaid(token)
paid = true
}
if (paid) {
if (window.navigator.vibrate) navigator.vibrate(200)
this.notifySuccess('Token paid.')
// this.$q.notify({
// timeout: 5000,
// type: 'positive',
// message: 'Token paid',
// position: 'top',
// actions: [
// {
// icon: 'close',
// color: 'white',
// handler: () => {}
// }
// ]
// })
} else {
console.log('### token not paid yet')
if (verbose) {
this.notify('Token still pending', (color = 'grey'))
// this.$q.notify({
// timeout: 5000,
// color: 'grey',
// message: 'Token still pending',
// position: 'top',
// actions: [
// {
// icon: 'close',
// color: 'white',
// handler: () => {}
// }
// ]
// })
}
this.sendData.tokens = token
}
return paid
},
findTokenForAmount: function (amount) {
// unused coin selection
for (const token of this.activeProofs) {
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]
}
}
}
},
////////////// WORKERS //////////////
clearAllWorkers: function () {
if (this.invoiceCheckListener) {
clearInterval(this.invoiceCheckListener)
}
if (this.tokensCheckSpendableListener) {
clearInterval(this.tokensCheckSpendableListener)
}
},
invoiceCheckWorker: async function () {
let nInterval = 0
this.clearAllWorkers()
this.invoiceCheckListener = setInterval(async () => {
try {
nInterval += 1
// exit loop after 2m
if (nInterval > 40) {
console.log('### stopping invoice check worker')
this.clearAllWorkers()
}
console.log('### invoiceCheckWorker setInterval', nInterval)
console.log(this.invoiceData)
// this will throw an error if the invoice is pending
await this.checkInvoice(this.invoiceData.hash, false)
// only without error (invoice paid) will we reach here
console.log('### stopping invoice check worker')
this.clearAllWorkers()
this.invoiceData.bolt11 = ''
this.showInvoiceDetails = false
if (window.navigator.vibrate) navigator.vibrate(200)
this.notifySuccess('Payment received', 'top')
} catch (error) {
console.log('not paid yet')
}
}, 3000)
},
checkTokenSpendableWorker: async function () {
let nInterval = 0
this.clearAllWorkers()
this.tokensCheckSpendableListener = setInterval(async () => {
try {
nInterval += 1
// exit loop after 2m
if (nInterval > 24) {
console.log('### stopping token check worker')
this.clearAllWorkers()
}
console.log('### checkTokenSpendableWorker setInterval', nInterval)
console.log(this.sendData)
// this will throw an error if the invoice is pending
paid = await this.checkTokenSpendable(
this.sendData.tokensBase64,
false
)
if (paid) {
console.log('### stopping token check worker')
this.clearAllWorkers()
this.sendData.tokens = ''
this.showSendTokens = false
}
} catch (error) {
console.log('not paid yet')
}
}, 3000)
},
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
////////////// UI HELPERS /////////////
assertMintError: function (response) {
if (!response.error == null) {
this.notifyError(response.error)
throw new Error(`Mint error: ${response.error}`)
}
},
notifyError: async function (message) {
this.$q.notify({
color: 'red',
message: message,
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
},
notify: async function (
message,
type = 'null',
position = 'top',
caption = null,
color = null
) {
// failure
this.$q.notify({
timeout: 5000,
type: type,
color: color,
message: message,
caption: caption,
position: position,
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
},
notifySuccess: async function (message, position = 'top') {
this.$q.notify({
timeout: 5000,
type: 'positive',
message: message,
position: position,
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
},
////////////// STORAGE /////////////
getLocalstorageToFile: async function () {
// https://stackoverflow.com/questions/24263682/save-restore-local-storage-to-a-local-file
const fileName = `cashu_backup_${currentDateStr()}.json`
var a = {}
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i)
var v = localStorage.getItem(k)
a[k] = v
}
var textToSave = JSON.stringify(a)
var textToSaveAsBlob = new Blob([textToSave], {
type: 'text/plain'
})
var textToSaveAsURL = window.URL.createObjectURL(textToSaveAsBlob)
var downloadLink = document.createElement('a')
downloadLink.download = fileName
downloadLink.innerHTML = 'Download File'
downloadLink.href = textToSaveAsURL
downloadLink.onclick = function () {
document.body.removeChild(event.target)
}
downloadLink.style.display = 'none'
document.body.appendChild(downloadLink)
downloadLink.click()
},
storeinvoicesCashu: function () {
localStorage.setItem(
'cashu.invoicesCashu',
JSON.stringify(this.invoicesCashu)
)
},
storehistoryTokens: function () {
localStorage.setItem(
'cashu.historyTokens',
JSON.stringify(this.historyTokens)
)
},
storeProofs: function () {
localStorage.setItem(
'cashu.proofs',
JSON.stringify(this.proofs, bigIntStringify)
)
},
mintKey: function (mintId, key) {
// returns a key for the local storage
// depending on the current mint
return 'cashu.' + mintId + '.' + key
}
},
watch: {
// payments: function () {
// this.getBalance()
// },
proofs: function () {
this.activeProofs = this.proofs.filter(p => this.keysets.includes(p.id))
}
},
mounted: function () {},
created: function () {
let params = new URL(document.location).searchParams
// load mints
if (this.$q.localStorage.getItem('cashu.mints')) {
this.mints = this.$q.localStorage.getItem('cashu.mints')
}
// mint url
if (params.get('mint_id')) {
this.mintId = params.get('mint_id')
// works with only lnbits mints
activeMintURL =
location.protocol +
'//' +
location.host +
`/cashu/api/v1/${this.mintId}`
this.walletURL = this.baseURL + '?mint_id=' + this.mintId
this.addMint(activeMintURL)
} else if (this.$q.localStorage.getItem('cashu.activeMintURL')) {
this.walletURL = this.baseURL
activeMintURL = this.$q.localStorage.getItem('cashu.activeMintURL')
this.addMint(activeMintURL)
} else {
this.walletURL = this.baseURL
this.notifyError('No mint set!')
this.tab = 'settings'
}
// todo: remove:
if (!this.mintId.length) {
this.mintId = 'dummy'
}
console.log('Mint URL ' + this.activeMintURL)
console.log('Wallet URL ' + this.walletURL)
// get name
if (params.get('mint_name')) {
this.mintName = params.get('mint_name')
}
if (this.activeMintURL.length) {
this.fetchMintKeys()
}
// const keysJson = localStorage.getItem(this.mintKey(this.mintId, 'keys'))
// if (!keysJson) {
// if (this.activeMintURL.length) {
// this.fetchMintKeys()
// }
// } else {
// this.keys = JSON.parse(keysJson)
// }
// this.invoicesCashu = JSON.parse(
// localStorage.getItem(this.mintKey(this.mintId, 'invoicesCashu')) || '[]'
// )
// this.historyTokens = JSON.parse(
// localStorage.getItem(this.mintKey(this.mintId, 'historyTokens')) || '[]'
// )
// this.proofs = JSON.parse(
// localStorage.getItem(this.mintKey(this.mintId, 'proofs')) || '[]'
// )
this.invoicesCashu = JSON.parse(
localStorage.getItem('cashu.invoicesCashu') || '[]'
)
this.historyTokens = JSON.parse(
localStorage.getItem('cashu.historyTokens') || '[]'
)
this.proofs = JSON.parse(localStorage.getItem('cashu.proofs') || '[]')
// get recv_token to receive tokens from a link
if (params.get('recv_token')) {
tokenBase64 = params.get('recv_token')
// make sure to react only to tokens not in the users history
let seen = false
for (var i = 0; i < this.historyTokens.length; i++) {
var thisToken = this.historyTokens[i].token
if (thisToken == tokenBase64 && this.historyTokens[i].amount > 0) {
seen = true
}
}
if (!seen) {
// show receive token dialog
this.receiveData.tokensBase64 = params.get('recv_token')
this.showReceiveTokens = true
}
}
// get lightning invoice from a link
if (params.get('lightning')) {
this.showParseDialog()
this.payInvoiceData.data.request = params.get('lightning')
}
// startup tasks
this.checkProofsSpendable(this.activeProofs, true).catch(err => {
return
})
this.checkPendingInvoices().catch(err => {
return
})
this.checkPendingTokens().catch(err => {
return
})
}
})
</script>
{% endblock %}