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

1505 lines
47 KiB
HTML

{% extends "public.html" %} {% block toolbar_title %} {% raw %} {{name}} Cashu
wallet {% endraw %} {% endblock %} {% block footer %}{% endblock %} {% block
page_container %}
<q-page-container>
<q-page>
<div class="row q-col-gutter-md justify-center q-pt-lg">
<div class="col-12 col-sm-8 col-md-9 col-lg-7 text-center q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row">
<div class="col-3">
<q-btn
size="18px"
rounded
color="secondary"
class="full-width"
@click="showInvoicesDialog"
>Create invoice
</q-btn>
</div>
<div class="col-6">
<h3 class="q-my-none">
<center>
<strong>{% raw %} {{balance}} </strong>
{{tickershort}}{% endraw %}
</center>
</h3>
</div>
<div class="col-3">
<q-btn
@click="showPayInvoiceDialog"
size="18px"
rounded
color="secondary"
class="full-width"
>Pay invoice
</q-btn>
</div>
</div>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col-sm-5 col-md-2">
<q-btn
size="18px"
icon="arrow_downward"
rounded
color="primary"
class="full-width"
@click="showReceiveTokensDialog"
>Receive</q-btn
>
</div>
<div class="col-sm-2 col-md-8"></div>
<div class="col-sm-5 col-md-2">
<q-btn
size="18px"
icon="arrow_upward"
rounded
color="primary"
class="full-width"
@click="showSendTokensDialog"
>
Send</q-btn
>
</div>
</div>
<q-tabs v-model="tab" no-caps class="bg-dark text-white shadow-2">
<q-tab name="tokens" label="Tokens"></q-tab>
<q-tab name="invoices" label="Invoices"></q-tab>
<q-tab name="history" label="History"></q-tab>
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="tokens">
<q-table
dense
flat
:data="tokenList"
:columns="tokensTable.columns"
:pagination.sync="tokensTable.pagination"
no-data-label="No tokens made yet"
:filter="tokensTable.filter"
>
{% raw %}
<template v-slot:body="props">
<q-tr :props="props">
<q-td
key="denomination"
:props="props"
:class="props.row.denomination > 0 ? 'text-green-13 text-weight-bold' : ''"
>
<div>{{props.row.denomination}}</div>
</q-td>
<q-td key="count" :props="props">
<div>{{props.row.count}}</div>
</q-td>
<q-td key="value" :props="props">
<div>{{props.row.value}}</div>
</q-td>
<q-td key="memo" :props="props">
<div>{{props.row.memo}}</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-tab-panel>
<q-tab-panel name="invoices">
<q-table
dense
flat
:data="invoicesCashu"
:columns="invoicesTable.columns"
:pagination.sync="invoicesTable.pagination"
no-data-label="No invoices made yet"
:filter="invoicesTable.filter"
>
{% raw %}
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="status" :props="props">
<div v-if="props.row.status == 'pending'">
<q-icon
@click="showInvoiceDialog(props.row)"
name="settings_ethernet"
color="grey"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
<q-badge
size="lg"
color="secondary"
class="q-mr-md cursor-pointer"
@click="recheckInvoice(props.row.hash)"
>
Recheck
</q-badge>
</div>
<div v-if="props.row.status === 'paid'">
<q-icon name="call_received" color="green"></q-icon>
</div>
</q-td>
<q-td
key="amount"
:props="props"
:class="props.row.amount > 0 ? 'text-green-13 text-weight-bold' : ''"
>
<div>{{props.row.amount}}</div>
</q-td>
<q-td key="memo" :props="props">
<div>{{props.row.memo}}</div>
</q-td>
<q-td key="date" :props="props">
<div>{{props.row.date}}</div>
</q-td>
<q-td key="hash" :props="props">
<div>{{props.row.hash}}</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-tab-panel>
<q-tab-panel name="history">
<span>History</span>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
</q-card>
</div>
<q-tabs
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
active-class="px-0"
indicator-color="transparent"
>
<q-tab
icon="arrow_right"
label="Create Invoice"
@click="showInvoicesDialog"
>
</q-tab>
<q-tab icon="arrow_downward" label="Receive Tokens"></q-tab>
<q-tab icon="arrow_upward" label="Send Token"></q-tab>
<q-tab icon="arrow_right" label="Pay Invoice"> </q-tab>
</q-tabs>
<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! If only mobile you can also click the 3 dots
and "Save to homescreen"/"Install app"</strong
>!
</p>
<p>
Ecash is a bearer asset, meaning you have the funds saved on this
page, losing the page without exporting the page will mean you will
lose the funds.
</p>
<div class="row q-mt-lg">
<q-btn outline color="grey">Copy wallet URL</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>I understand</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showInvoiceDetails" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!invoiceData.bolt11">
<div class="row items-center no-wrap q-mb-sm">
<div class="col-12">
<span class="text-subtitle1">Create a Lightning invoice</span>
</div>
</div>
<q-input
filled
dense
v-model.number="invoiceData.amount"
label="Amount ({{LNBITS_DENOMINATION}}) *"
mask="#.##"
fill-mask="0"
reverse-fill-mask
type="number"
class="q-mb-lg"
></q-input>
<q-input
filled
dense
v-model.trim="invoiceData.memo"
label="Memo"
></q-input>
</div>
<div v-else class="text-center q-mb-lg">
<a :href="'lightning:' + invoiceData.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="invoiceData.bolt11"
:options="{width: 340}"
class="rounded-borders"
>
</qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="invoiceData.bolt11"
@click="copyText(invoiceData.bolt11)"
outline
color="grey"
>Copy invoice</q-btn
>
<q-btn v-else outline color="grey" @click="requestMint"
>Request Invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showSendTokens" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!sendData.tokens">
<div class="row items-center no-wrap q-mb-sm">
<div class="col-12">
<span class="text-subtitle1"
>How much would you like to send?</span
>
</div>
</div>
<q-input
filled
dense
v-model.number="sendData.amount"
label="Amount (tokens) *"
type="number"
class="q-mb-lg"
></q-input>
<q-input
filled
dense
v-model.trim="sendData.memo"
label="Memo"
></q-input>
</div>
<div v-else class="text-center q-mb-lg">
<q-input
filled
dense
v-model="sendData.tokensBase64"
label="tokens"
type="textarea"
class="q-mb-lg"
></q-input>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="!sendData.tokens"
@click="sendTokens"
outline
color="grey"
>Send Tokens</q-btn
>
<q-btn v-else @click="burnTokens" outline color="grey"
>Burn Tokens</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showReceiveTokens" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div>
<div class="row items-center no-wrap q-mb-sm">
<div class="col-12">
<span class="text-subtitle1">Paste tokens please</span>
</div>
</div>
<q-input
filled
dense
v-model="receiveData.tokensBase64"
label="tokens"
type="textarea"
class="q-mb-lg"
></q-input>
</div>
<div class="row q-mt-lg">
<q-btn @click="receiveTokens" outline color="grey"
>Receive Tokens</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showPayInvoice" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!payInvoiceData.invoice">
<div class="row items-center no-wrap q-mb-sm">
<div class="col-12">
<span class="text-subtitle1"
>Please paste a Lightning invoice</span
>
</div>
</div>
<q-input
filled
dense
v-model="payInvoiceData.bolt11"
label="Paste invoice"
type="textarea"
class="q-mb-lg"
></q-input>
</div>
<div v-else class="q-mb-lg">
{% raw %}
<strong>Amount:</strong> {{ payInvoiceData.invoice.sat }}
<strong>sats</strong><br />
<strong>Description:</strong> {{ payInvoiceData.invoice.description
}}<br />
<strong>Expire date:</strong> {{ payInvoiceData.invoice.expireDate
}}<br />
<strong>Expired:</strong> {{ payInvoiceData.invoice.expired }}<br />
<strong>Hash:</strong> {{ payInvoiceData.invoice.hash }} {% endraw
%}
</div>
<div class="row q-mt-lg">
<q-btn
v-if="!payInvoiceData.invoice"
@click="checkInvoice"
outline
color="grey"
>Check Invoice</q-btn
>
<q-btn v-else outline color="grey" @click="melt">Pay invoice</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</q-card>
</q-dialog>
</div>
</q-page>
</q-page-container>
{% endblock %} {% block styles %}
<style>
* {
touch-action: manipulation;
}
.keypad {
display: grid;
grid-gap: 8px;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
}
.keypad .btn {
height: 100%;
}
.keypad .btn-confirm {
grid-area: 1 / 4 / 5 / 4;
}
</style>
{% endblock %} {% block scripts %}
<script src="{{ url_for('cashu_static', path='js/noble-secp256k1.js') }}"></script>
<script src="{{ url_for('cashu_static', path='js/utils.js') }}"></script>
<script src="{{ url_for('cashu_static', path='js/dhke.js') }}"></script>
<script src="{{ url_for('cashu_static', path='js/base64.js') }}"></script>
<script>
var currentDateStr = function () {
return Quasar.utils.date.formatDate(new Date(), 'YYYY-MM-DD HH:mm')
}
var mapMint = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.cashu = ['/cashu/', obj.id].join('')
return obj
}
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
tickershort: '',
name: '',
mintId: '',
mintName: '',
keys: '',
invoicesCashu: [],
invoiceData: {
amount: 0,
memo: '',
bolt11: '',
hash: ''
},
payInvoiceData: {
invoice: '',
bolt11: ''
},
sendData: {
amount: 0,
memo: '',
tokens: '',
tokensBase64: ''
},
receiveData: {
tokensBase64: ''
},
showInvoiceDetails: false,
showPayInvoice: false,
showSendTokens: false,
showReceiveTokens: false,
promises: [],
tokens: [],
tab: 'tokens',
receive: {
show: false,
status: 'pending',
paymentReq: null,
paymentHash: null,
minMax: [0, 2100000000000000],
lnurl: null,
units: ['sat'],
unit: 'sat',
data: {
amount: null,
memo: ''
}
},
parse: {
show: false,
invoice: null,
lnurlpay: null,
lnurlauth: null,
data: {
request: '',
amount: 0,
comment: ''
},
paymentChecker: null,
camera: {
show: false,
camera: 'auto'
}
},
payments: [],
invoicesTable: {
columns: [
{
name: 'status',
align: 'left',
label: '',
field: 'status'
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount'
},
{
name: 'memo',
align: 'left',
label: 'Memo',
field: 'memo',
sortable: true
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
},
{
name: 'hash',
align: 'right',
label: 'Hash',
field: 'hash',
sortable: true
}
],
pagination: {
rowsPerPage: 10
},
filter: null
},
tokensTable: {
columns: [
{
name: 'denomination',
align: 'left',
label: 'Denomination',
field: 'denomination',
sortable: true
},
{
name: 'count',
align: 'left',
label: 'Count',
field: 'count',
sortable: true
},
{
name: 'value',
align: 'left',
label: 'Value',
field: 'value',
sortable: true
},
{
name: 'memo',
align: 'left',
label: 'Memo',
field: 'memo',
sortable: true
}
],
pagination: {
rowsPerPage: 10
},
filter: null
},
paymentsChart: {
show: false
},
disclaimerDialog: {
show: false,
location: window.location
},
credit: 0,
newName: ''
}
},
computed: {
formattedBalance: function () {
return this.balance / 100
},
canPay: function () {
if (!this.parse.invoice) return false
return this.parse.invoice.sat <= this.balance
},
pendingPaymentsExist: function () {
return this.payments.findIndex(payment => payment.pending) !== -1
},
tokenList: function () {
const x = this.proofs
.filter(t => t.promises?.length)
.map(t => t.blindedMessages)
.flat()
.map(t => ({
blindingFactor: t.B_,
denomination: t.amount
}))
.reduce((y, t) => {
y[`_${t.denomination}`] = y[`_${t.denomination}`] || []
y[`_${t.denomination}`].push(t)
return y
}, {})
return Object.keys(x).map(k => ({
denomination: x[k][0].denomination,
count: x[k].length,
value: x[k][0].denomination * x[k].length
}))
},
balance: function () {
return this.proofs
.map(t => t)
.flat()
.reduce((sum, el) => (sum += el.amount), 0)
}
},
filters: {
msatoshiFormat: function (value) {
return LNbits.utils.formatSat(value / 1000)
}
},
methods: {
paymentTableRowKey: function (row) {
return row.payment_hash + row.amount
},
closeCamera: function () {
this.parse.camera.show = false
},
showCamera: function () {
this.parse.camera.show = true
},
showChart: function () {
this.paymentsChart.show = true
this.$nextTick(() => {
generateChart(this.$refs.canvas, this.payments)
})
},
focusInput(el) {
this.$nextTick(() => this.$refs[el].focus())
},
showReceiveDialog: function () {
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = null
this.receive.data.memo = null
this.receive.unit = 'sat'
this.receive.paymentChecker = null
this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null
this.focusInput('setAmount')
},
showParseDialog: function () {
this.parse.show = true
this.parse.invoice = null
this.parse.lnurlpay = null
this.parse.lnurlauth = null
this.parse.data.request = ''
this.parse.data.comment = ''
this.parse.data.paymentChecker = null
this.parse.camera.show = false
},
closeReceiveDialog: function () {
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 10000)
},
closeParseDialog: function () {
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 10000)
},
onPaymentReceived: function (paymentHash) {
this.fetchPayments()
this.fetchBalance()
if (this.receive.paymentHash === paymentHash) {
this.receive.show = false
this.receive.paymentHash = null
clearInterval(this.receive.paymentChecker)
}
},
createInvoice: function () {
this.receive.status = 'loading'
if (LNBITS_DENOMINATION != 'sats') {
this.receive.data.amount = this.receive.data.amount * 100
}
LNbits.api
.createInvoice(
this.receive.data.amount,
this.receive.data.memo,
this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback
)
.then(response => {
this.receive.status = 'success'
this.receive.paymentReq = response.data.payment_request
this.receive.paymentHash = response.data.payment_hash
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
}
if (typeof response.data.lnurl_response === 'string') {
// failure
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
caption: response.data.lnurl_response
})
return
} else if (response.data.lnurl_response === true) {
// success
this.$q.notify({
timeout: 5000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true
})
}
}
clearInterval(this.receive.paymentChecker)
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 40000)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
this.receive.status = 'pending'
})
},
decodeQR: function (res) {
this.parse.data.request = res
this.decodeRequest()
this.parse.camera.show = false
},
decodeRequest: function () {
this.parse.show = true
let req = this.parse.data.request.toLowerCase()
if (this.parse.data.request.toLowerCase().startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10)
} else if (this.parse.data.request.toLowerCase().startsWith('lnurl:')) {
this.parse.data.request = this.parse.data.request.slice(6)
} else if (req.indexOf('lightning=lnurl1') !== -1) {
this.parse.data.request = this.parse.data.request
.split('lightning=')[1]
.split('&')[0]
}
if (
this.parse.data.request.toLowerCase().startsWith('lnurl1') ||
this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
) {
return
}
let invoice
try {
invoice = decode(this.parse.data.request)
} catch (error) {
this.$q.notify({
timeout: 3000,
type: 'warning',
message: error + '.',
caption: '400 BAD REQUEST'
})
this.parse.show = false
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.parse.invoice = Object.freeze(cleanInvoice)
},
payInvoice: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...'
})
},
payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...'
})
},
authLnurl: function () {
let dismissAuthMsg = this.$q.notify({
timeout: 10,
message: 'Performing authentication...'
})
},
deleteWallet: function (walletId, user) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?')
.onOk(() => {
LNbits.href.deleteWallet(walletId, user)
})
},
fetchPayments: function () {
return
},
fetchBalance: function () {},
exportCSV: function () {
// status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons
// and would cause duplication in the list
let columns = this.paymentsTable.columns
columns.unshift({
name: 'pending',
align: 'left',
label: 'Pending',
field: 'pending'
})
LNbits.utils.exportCSV(columns, this.payments)
},
/////////////////////////////////// WALLET ///////////////////////////////////
showInvoicesDialog: async function () {
console.log('##### showInvoicesDialog')
this.invoiceData.amount = 0
this.invoiceData.bolt11 = ''
this.invoiceData.hash = ''
this.invoiceData.memo = ''
this.showInvoiceDetails = true
},
showInvoiceDialog: function (data) {
this.invoiceData = _.clone(data)
this.showInvoiceDetails = true
},
showPayInvoiceDialog: function () {
console.log('### showPayInvoiceDialog')
this.payInvoiceData.invoice = ''
this.payInvoiceData.bolt11 = ''
this.showPayInvoice = true
},
showSendTokensDialog: function () {
this.sendData.tokens = ''
this.sendData.tokensBase64 = ''
this.sendData.amount = 0
this.sendData.memo = ''
this.showSendTokens = true
},
showReceiveTokensDialog: function () {
this.receiveData.tokensBase64 = ''
this.showReceiveTokens = true
},
recheckPendingInvoices: async function () {
for (const invoice of this.invoicesCashu) {
if (invoice.status === 'pending') {
this.recheckInvoice(invoice.hash)
// try {
// const {data} = await LNbits.api.request(
// 'POST',
// `/cashu/api/v1/${this.mintId}/mint?payment_hash=${invoice.hash}`,
// '',
// {
// blinded_messages: []
// }
// )
// console.log('### data', data)
// } catch (error) {
// console.error(error)
// LNbits.utils.notifyApiError(error)
// }
}
}
},
//////////////////////// MINT //////////////////////////////////////////
requestMint: async function () {
// gets an invoice from the mint to get new tokens
try {
const {data} = await LNbits.api.request(
'GET',
`/cashu/api/v1/${this.mintId}/mint?amount=${this.invoiceData.amount}`
)
console.log('### data', data)
this.invoiceData.bolt11 = data.pr
this.invoiceData.hash = data.hash
this.invoicesCashu.push({
..._.clone(this.invoiceData),
date: currentDateStr(),
status: 'pending'
})
this.storeinvoicesCashu()
this.tab = 'invoices'
return data
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
}
},
mintApi: async function (amounts, payment_hash) {
console.log('### promises', payment_hash)
try {
let secrets = await this.generateSecrets(amounts)
let {blindedMessages, rs} = await this.constructOutputs(
amounts,
secrets
)
const promises = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/mint?payment_hash=${payment_hash}`,
'',
{
blinded_messages: blindedMessages
}
)
console.log('### promises data', promises.data)
let proofs = await this.constructProofs(promises.data, secrets, rs)
return proofs
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
}
},
mint: async function (amount, payment_hash) {
try {
const split = splitAmount(amount)
const proofs = await this.mintApi(split, payment_hash)
this.proofs.push(...proofs)
this.storeProofs()
await this.setInvoicePaid(payment_hash)
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
}
},
setInvoicePaid: async function (payment_hash) {
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
invoice.status = 'paid'
this.storeinvoicesCashu()
},
recheckInvoice: async function (payment_hash) {
console.log('### recheckInvoice.hash', payment_hash)
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
this.mint(invoice.amount, invoice.hash)
},
// requestTokens: async function (amounts, paymentHash) {
// const newTokens = await this.buildTokens(amounts, paymentHash)
// // this.proofs.push(newTokens)
// // this.storeProofs()
// // console.log('### this.proofs', this.proofs)
// // await this.mint(paymentHash, newTokens.newTokens)
// },
generateSecrets: async function (amounts) {
const secrets = []
for (let i = 0; i < amounts.length; i++) {
const secret = nobleSecp256k1.utils.randomBytes(32)
secrets.push(secret)
}
return secrets
},
constructOutputs: async function (amounts, secrets) {
const blindedMessages = []
const rs = []
for (let i = 0; i < amounts.length; i++) {
const {B_, r} = await step1Alice(secrets[i])
blindedMessages.push({amount: amounts[i], B_: B_})
rs.push(r)
}
return {
blindedMessages,
rs
}
},
constructProofs: function (promises, secrets, rs) {
const proofs = []
for (let i = 0; i < promises.length; i++) {
const encodedSecret = uint8ToBase64.encode(secrets[i])
let {id, amount, C, secret} = this.promiseToProof(
promises[i].id,
promises[i].amount,
promises[i]['C_'],
encodedSecret,
rs[i]
)
proofs.push({id, amount, C, secret})
}
return proofs
},
promiseToProof: function (id, amount, C_hex, secret, r) {
const C_ = nobleSecp256k1.Point.fromHex(C_hex)
const A = this.keys[amount]
const C = step3Alice(C_, r, nobleSecp256k1.Point.fromHex(A))
return {
id,
amount,
C: C.toHex(true),
secret
}
},
sumProofs: function (proofs) {
return proofs.reduce((s, t) => (s += t.amount), 0)
},
splitToSend: async function (proofs, amount) {
try {
const spendableProofs = proofs.filter(p => !p.reserved)
if (this.sumProofs(spendableProofs) < amount) {
throw new Error('balance too low.')
}
let {fristProofs, scndProofs} = await this.split(
spendableProofs,
amount
)
// keep firstProofs, send scndProofs
// set scndProofs in this.proofs as reserved
const usedSecrets = proofs.map(p => p.secret)
for (let i = 0; i < this.proofs.length; i++) {
if (usedSecrets.includes(this.proofs[i].secret)) {
this.proofs[i].reserved = true
}
}
this.storeProofs()
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
}
},
split: async function (proofs, amount) {
try {
if (proofs.length == 0) {
throw new Error('no proofs provided.')
}
let {fristProofs, scndProofs} = await this.splitApi(proofs, amount)
// delete proofs from this.proofs
const usedSecrets = proofs.map(p => p.secret)
this.proofs = this.proofs.filter(p => !usedSecrets.includes(p.secret))
// add new fristProofs, scndProofs to this.proofs
this.proofs.push(...fristProofs)
this.proofs.push(...scndProofs)
this.storeProofs()
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
}
},
splitApi: async function (proofs, amount) {
try {
const total = this.sumProofs(proofs)
const frst_amount = total - amount
const scnd_amount = amount
const frst_amounts = splitAmount(frst_amount)
const scnd_amounts = splitAmount(scnd_amount)
const amounts = _.clone(frst_amounts)
amounts.push(...scnd_amounts)
let secrets = await this.generateSecrets(amounts)
if (secrets.length != amounts.length) {
throw new Error(
'number of secrets does not match number of outputs.'
)
}
let {blindedMessages, rs} = await this.constructOutputs(
amounts,
secrets
)
const payload = {
amount,
proofs,
outputs: {
blinded_messages: blindedMessages
}
}
console.log('payload', JSON.stringify(payload))
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/split`,
'',
payload
)
const frst_rs = rs.slice(0, frst_amounts.length)
const frst_secrets = secrets.slice(0, frst_amounts.length)
const scnd_rs = rs.slice(frst_amounts.length)
const scnd_secrets = secrets.slice(frst_amounts.length)
const fristProofs = this.constructProofs(
data.fst,
frst_secrets,
frst_rs
)
const scndProofs = this.constructProofs(
data.snd,
scnd_secrets,
scnd_rs
)
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
}
},
////////////////////////////////////////////////////////////////////////////////////
receiveTokens: async function () {
this.showReceiveTokens = false
console.log('### receive tokens', this.receiveData.tokensBase64)
if (this.receiveData.tokensBase64) {
const tokensJson = atob(this.receiveData.tokensBase64)
const proofs = JSON.parse(tokensJson)
const amount = proofs.reduce((s, t) => (s += t.amount), 0)
const amounts = splitAmount(amount)
const newTokens = await this.buildTokens(amounts)
console.log('newTokens', newTokens)
const payload = {
amount,
proofs,
outputs: {
blinded_messages: newTokens.blindedMessages
}
}
console.log('payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/split`,
'',
payload
)
newTokens.promises = data.snd
// console.log('split data', JSON.stringify(data.snd))
// for (let i =0 ;i < newTokens.length; i++) {
// Object.assign(newTokens[i], promises)
// }
console.log('newTokens 2', newTokens)
this.proofs.push(newTokens)
this.storeProofs()
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
}
}
},
buildTokens: async function (amounts, paymentHash) {
const blindedMessages = []
const secrets = []
const rs = []
for (let i = 0; i < amounts.length; i++) {
const secret = nobleSecp256k1.utils.randomBytes(32)
// const secret = nobleSecp256k1.utils.hexToBytes('0000000000000000000000000000000000000000000000000000000000000003')
// todo: base64Url
const encodedSecret = uint8ToBase64.encode(secret)
secrets.push(encodedSecret)
const {B_, r} = await step1Alice(secret)
rs.push(r)
blindedMessages.push({amount: amounts[i], B_: B_})
}
const newTokens = {
hash: paymentHash,
blindedMessages,
rs,
secrets,
status: 'pending'
}
return newTokens
},
sendTokens: async function () {
// keep firstProofs, send scndProofs
let {fristProofs, scndProofs} = await this.splitToSend(
this.proofs,
this.sendData.amount
)
// const amounts = splitAmount(this.sendData.amount)
// const sendTokens = []
// sendTokens.push(this.proofs)
// for (const amount of amounts) {
// const token = this.findTokenForAmount(amount)
// if (token) {
// sendTokens.push(token)
// } else {
// this.$q.notify({
// timeout: 5000,
// type: 'warning',
// message: `Cannot select amount for denomination ${amount}`
// })
// this.sendData.tokens = ''
// this.sendData.tokensBase64 = ''
// return
// }
// }
this.sendData.tokens = ''
this.sendData.tokensBase64 = ''
console.log('### sendTokens', sendTokens)
// this.sendData.tokens = sendTokens.map((token, tokenIndex) => {
// return this.promiseToProof(
// token.promises[tokenIndex].amount,
// token.promises[tokenIndex]['C_'],
// token.promises[tokenIndex].secret,
// token.promises[tokenIndex].r
// )
// })
this.sendData.tokens = scndProofs
console.log('### this.sendData.tokens', this.sendData.tokens)
this.sendData.tokensBase64 = btoa(JSON.stringify(this.sendData.tokens))
},
burnTokens: function () {
for (const sentToken of this.sendData.tokens) {
for (const token of this.proofs) {
if (token.status === 'paid') {
const secretIndex = token.secrets.findIndex(
s => s === sentToken.secret
)
console.log('### secretIndex', secretIndex)
if (secretIndex >= 0) {
token.blindedMessages?.splice(secretIndex, 1)
token.promises?.splice(secretIndex, 1)
token.rs?.splice(secretIndex, 1)
token.secrets?.splice(secretIndex, 1)
}
}
}
}
this.$q.notify({
timeout: 5000,
message: 'Tokens burned'
})
this.storeProofs()
this.showSendTokens = false
console.log('### this.proofs', this.proofs)
},
findTokenForAmount: function (amount) {
for (const token of this.proofs) {
const index = token.promises?.findIndex(p => p.amount === amount)
if (index >= 0) {
return {
promise: token.promises[index],
secret: token.secrets[index],
r: token.rs[index]
}
}
}
},
checkInvoice: function () {
console.log('#### checkInvoice')
try {
const invoice = decode(this.payInvoiceData.bolt11)
const cleanInvoice = {
msat: invoice.human_readable_part.amount,
sat: invoice.human_readable_part.amount / 1000,
fsat: LNbits.utils.formatSat(
invoice.human_readable_part.amount / 1000
)
}
_.each(invoice.data.tags, tag => {
if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description === 'payment_hash') {
cleanInvoice.hash = tag.value
} else if (tag.description === 'description') {
cleanInvoice.description = tag.value
} else if (tag.description === 'expiry') {
var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000
)
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
cleanInvoice.expired = false // TODO
}
}
this.payInvoiceData.invoice = cleanInvoice
})
console.log(
'#### this.payInvoiceData.invoice',
this.payInvoiceData.invoice
)
} catch (error) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Cannot decode invoice',
caption: error + ''
})
}
},
melt: async function () {
console.log('#### sell tokens')
const amount = this.payInvoiceData.invoice.sat
const paidTokens = this.proofs.filter(t => t.promises?.length)
console.log('### paidTokens', paidTokens)
const proofs = paidTokens.map(token => {
return token.promises.map((promise, promiseIndex) => {
console.log('### promise', promise)
const secret = token.secrets[promiseIndex]
const r = token.rs[promiseIndex]
return this.promiseToProof(promise.amount, promise['C_'], secret, r)
})
})
const payload = {
proofs: proofs.flat(),
amount,
invoice: this.payInvoiceData.bolt11
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/melt`,
'',
payload
)
this.$q.notify({
timeout: 5000,
message: 'Invoice paid'
})
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
}
},
// C_hex = promise['C_']
// amount = promise.amount
fetchMintKeys: async function () {
const {data} = await LNbits.api.request(
'GET',
`/cashu/api/v1/${this.mintId}/keys`
)
this.keys = data
localStorage.setItem('cashu.keys', JSON.stringify(data))
},
storeinvoicesCashu: function () {
localStorage.setItem(
'cashu.invoicesCashu',
JSON.stringify(this.invoicesCashu)
)
},
storeProofs: function () {
localStorage.setItem(
'cashu.proofs',
JSON.stringify(this.proofs, bigIntStringify)
)
}
},
watch: {
payments: function () {
this.fetchBalance()
}
},
created: function () {
let params = new URL(document.location).searchParams
// get ticker
if (
!params.get('tsh') &&
!this.$q.localStorage.getItem('cashu.tickershort')
) {
this.$q.localStorage.set('cashu.tickershort', 'sats')
this.tickershort = 'sats'
} else if (params.get('tsh')) {
this.$q.localStorage.set('cashu.tickershort', params.get('tsh'))
this.tickershort = params.get('tsh')
} else if (this.$q.localStorage.getItem('cashu.tickershort')) {
this.tickershort = this.$q.localStorage.getItem('cashu.tickershort')
}
// get mint
if (params.get('mint_id')) {
this.mintId = params.get('mint_id')
this.$q.localStorage.set('cashu.mint', params.get('mint_id'))
} else if (this.$q.localStorage.getItem('cashu.mint')) {
this.mintId = this.$q.localStorage.getItem('cashu.mint')
} else {
this.$q.notify({
color: 'red',
message: 'No mint set!'
})
}
// get name
if (params.get('mint_name')) {
this.mintName = params.get('mint_name')
this.$q.localStorage.set('cashu.mintName', params.get('mint_name'))
} else if (this.$q.localStorage.getItem('cashu.name')) {
this.mintName = this.$q.localStorage.getItem('cashu.name')
}
const keysJson = localStorage.getItem('cashu.keys')
if (!keysJson) {
this.fetchMintKeys()
} else {
this.keys = JSON.parse(keysJson)
}
this.invoicesCashu = JSON.parse(
localStorage.getItem('cashu.invoicesCashu') || '[]'
)
this.proofs = JSON.parse(localStorage.getItem('cashu.proofs') || '[]')
console.log('### invoicesCashu', this.invoicesCashu)
console.table('### tokens', this.proofs)
console.log('#### this.mintId', this.mintId)
console.log('#### this.mintName', this.mintName)
this.recheckPendingInvoices()
}
})
</script>
{% endblock %}