2623 lines
84 KiB
HTML
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 %}
|