From b96bdf25f65b3ecb00af3a5cf14ff17636b6d041 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 22 Jul 2022 16:27:35 +0300 Subject: [PATCH] feat: basic use of custom components --- .../components/my-checkbox/my-checkbox.html | 5 + .../js/components/my-checkbox/my-checkbox.js | 18 + .../extensions/watchonly/static/js/index.js | 2357 +++++++++-------- .../extensions/watchonly/static/js/utils.js | 20 + .../watchonly/templates/watchonly/index.html | 5 +- 5 files changed, 1231 insertions(+), 1174 deletions(-) create mode 100644 lnbits/extensions/watchonly/static/js/components/my-checkbox/my-checkbox.html create mode 100644 lnbits/extensions/watchonly/static/js/components/my-checkbox/my-checkbox.js diff --git a/lnbits/extensions/watchonly/static/js/components/my-checkbox/my-checkbox.html b/lnbits/extensions/watchonly/static/js/components/my-checkbox/my-checkbox.html new file mode 100644 index 000000000..0561e09a8 --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/components/my-checkbox/my-checkbox.html @@ -0,0 +1,5 @@ +
+
+
{{ title }}
+ XXX +
\ No newline at end of file diff --git a/lnbits/extensions/watchonly/static/js/components/my-checkbox/my-checkbox.js b/lnbits/extensions/watchonly/static/js/components/my-checkbox/my-checkbox.js new file mode 100644 index 000000000..e24581d6b --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/components/my-checkbox/my-checkbox.js @@ -0,0 +1,18 @@ +async function initMyCheckbox(path) { + const t = await loadTemplateAsync(path) + Vue.component('my-checkbox', { + name:'my-checkbox', + template: t, + data() { + return { checked: false, title: 'Check me' } + }, + methods: { + check() { + this.checked = !this.checked; + console.log('### checked', this.checked) + } + } + }); +} + + diff --git a/lnbits/extensions/watchonly/static/js/index.js b/lnbits/extensions/watchonly/static/js/index.js index 9db60c83c..63c765f6a 100644 --- a/lnbits/extensions/watchonly/static/js/index.js +++ b/lnbits/extensions/watchonly/static/js/index.js @@ -1,1219 +1,1230 @@ -Vue.component(VueQrcode.name, VueQrcode) +const watchOnly = async () => { + Vue.component(VueQrcode.name, VueQrcode) + await initMyCheckbox('static/js/components/my-checkbox/my-checkbox.html') -Vue.filter('reverse', function (value) { - // slice to make a copy of array, then reverse the copy - return value.slice().reverse() -}) + Vue.filter('reverse', function (value) { + // slice to make a copy of array, then reverse the copy + return value.slice().reverse() + }) -new Vue({ - el: '#vue', - mixins: [windowMixin], - data: function () { - return { - DUST_LIMIT: 546, - filter: '', - - scan: { - scanning: false, - scanCount: 0, - scanIndex: 0 - }, - - currentAddress: null, - - tab: 'addresses', - - config: { - data: { - mempool_endpoint: 'https://mempool.space', - receive_gap_limit: 20, - change_gap_limit: 5 - }, - DEFAULT_RECEIVE_GAP_LIMIT: 20, - show: false - }, - - serial: { - selectedPort: null, - writableStreamClosed: null, - writer: null, - readableStreamClosed: null, - reader: null, - showAdvancedConfig: false, - receivedData: '', - config: {} - }, - - hww: { - password: null, - showPassword: false, - mnemonic: null, - showMnemonic: false, - authenticated: false, - showPasswordDialog: false, - showWipeDialog: false, - showRestoreDialog: false, - showConsole: false, - showSignedPsbt: false, - sendingPsbt: false, - signingPsbt: false, - psbtSent: false - }, - - formDialog: { - show: false, - data: {} - }, - - qrCodeDialog: { - show: false, - data: null - }, - ...tables, - ...tableData - } - }, - - methods: { - //################### CONFIG ################### - getConfig: async function () { - try { - const {data} = await LNbits.api.request( - 'GET', - '/watchonly/api/v1/config', - this.g.user.wallets[0].adminkey - ) - this.config.data = data - } catch (error) { - LNbits.utils.notifyApiError(error) - } + new Vue({ + el: '#vue', + mixins: [windowMixin], + mounted: function () { + console.log('### mounted') }, - updateConfig: async function () { - const wallet = this.g.user.wallets[0] - try { - await LNbits.api.request( - 'PUT', - '/watchonly/api/v1/config', - wallet.adminkey, - this.config.data - ) - this.config.show = false - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - //################### WALLETS ################### - getWalletName: function (walletId) { - const wallet = this.walletAccounts.find(wl => wl.id === walletId) - return wallet ? wallet.title : 'unknown' - }, - addWalletAccount: async function () { - const wallet = this.g.user.wallets[0] - const data = _.omit(this.formDialog.data, 'wallet') - await this.createWalletAccount(wallet, data) - }, - createWalletAccount: async function (wallet, data) { - try { - const response = await LNbits.api.request( - 'POST', - '/watchonly/api/v1/wallet', - wallet.adminkey, - data - ) - this.walletAccounts.push(mapWalletAccount(response.data)) - this.formDialog.show = false - - await this.refreshWalletAccounts() - await this.refreshAddresses() - - if (!this.payment.changeWallett) { - this.payment.changeWallet = this.walletAccounts[0] - this.selectChangeAddress(this.payment.changeWallet) - } - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - deleteWalletAccount: function (walletAccountId) { - LNbits.utils - .confirmDialog( - 'Are you sure you want to delete this watch only wallet?' - ) - .onOk(async () => { - try { - await LNbits.api.request( - 'DELETE', - '/watchonly/api/v1/wallet/' + walletAccountId, - this.g.user.wallets[0].adminkey - ) - this.walletAccounts = _.reject(this.walletAccounts, function (obj) { - return obj.id === walletAccountId - }) - await this.refreshWalletAccounts() - await this.refreshAddresses() - if ( - this.payment.changeWallet && - this.payment.changeWallet.id === walletAccountId - ) { - this.payment.changeWallet = this.walletAccounts[0] - this.selectChangeAddress(this.payment.changeWallet) - } - await this.scanAddressWithAmount() - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Error while deleting wallet account. Please try again.', - timeout: 10000 - }) - } - }) - }, - getAddressesForWallet: async function (walletId) { - try { - const {data} = await LNbits.api.request( - 'GET', - '/watchonly/api/v1/addresses/' + walletId, - this.g.user.wallets[0].inkey - ) - return data.map(mapAddressesData) - } catch (err) { - this.$q.notify({ - type: 'warning', - message: `Failed to fetch addresses for wallet with id ${walletId}.`, - timeout: 10000 - }) - LNbits.utils.notifyApiError(err) - } - return [] - }, - getWatchOnlyWallets: async function () { - try { - const {data} = await LNbits.api.request( - 'GET', - '/watchonly/api/v1/wallet', - this.g.user.wallets[0].inkey - ) - return data - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to fetch wallets.', - timeout: 10000 - }) - LNbits.utils.notifyApiError(error) - } - return [] - }, - refreshWalletAccounts: async function () { - const wallets = await this.getWatchOnlyWallets() - this.walletAccounts = wallets.map(w => mapWalletAccount(w)) - }, - getAmmountForWallet: function (walletId) { - const amount = this.addresses.data - .filter(a => a.wallet === walletId) - .reduce((t, a) => t + a.amount || 0, 0) - return this.satBtc(amount) - }, - - //################### ADDRESSES ################### - - refreshAddresses: async function () { - const wallets = await this.getWatchOnlyWallets() - this.addresses.data = [] - for (const {id, type} of wallets) { - const newAddresses = await this.getAddressesForWallet(id) - const uniqueAddresses = newAddresses.filter( - newAddr => - !this.addresses.data.find(a => a.address === newAddr.address) - ) - - const lastAcctiveAddress = - uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() || {} - - uniqueAddresses.forEach(a => { - a.expanded = false - a.accountType = type - a.gapLimitExceeded = - !a.isChange && - a.addressIndex > - lastAcctiveAddress.addressIndex + - this.config.DEFAULT_RECEIVE_GAP_LIMIT - }) - this.addresses.data.push(...uniqueAddresses) - } - }, - updateAmountForAddress: async function (addressData, amount = 0) { - try { - const wallet = this.g.user.wallets[0] - addressData.amount = amount - if (!addressData.isChange) { - const addressWallet = this.walletAccounts.find( - w => w.id === addressData.wallet - ) - if ( - addressWallet && - addressWallet.address_no < addressData.addressIndex - ) { - addressWallet.address_no = addressData.addressIndex - } - } - - await LNbits.api.request( - 'PUT', - `/watchonly/api/v1/address/${addressData.id}`, - wallet.adminkey, - {amount} - ) - } catch (err) { - addressData.error = 'Failed to refresh amount for address' - this.$q.notify({ - type: 'warning', - message: `Failed to refresh amount for address ${addressData.address}`, - timeout: 10000 - }) - LNbits.utils.notifyApiError(err) - } - }, - updateNoteForAddress: async function (addressData, note) { - try { - const wallet = this.g.user.wallets[0] - await LNbits.api.request( - 'PUT', - `/watchonly/api/v1/address/${addressData.id}`, - wallet.adminkey, - {note: addressData.note} - ) - const updatedAddress = - this.addresses.data.find(a => a.id === addressData.id) || {} - updatedAddress.note = note - } catch (err) { - LNbits.utils.notifyApiError(err) - } - }, - getFilteredAddresses: function () { - const selectedWalletId = this.addresses.selectedWallet?.id - const filter = this.addresses.filterValues || [] - const includeChangeAddrs = filter.includes('Show Change Addresses') - const includeGapAddrs = filter.includes('Show Gap Addresses') - const excludeNoAmount = filter.includes('Only With Amount') - - const walletsLimit = this.walletAccounts.reduce((r, w) => { - r[`_${w.id}`] = w.address_no - return r - }, {}) - - const addresses = this.addresses.data.filter( - a => - (includeChangeAddrs || !a.isChange) && - (includeGapAddrs || - a.isChange || - a.addressIndex <= walletsLimit[`_${a.wallet}`]) && - !(excludeNoAmount && a.amount === 0) && - (!selectedWalletId || a.wallet === selectedWalletId) - ) - return addresses - }, - openGetFreshAddressDialog: async function (walletId) { - const {data} = await LNbits.api.request( - 'GET', - `/watchonly/api/v1/address/${walletId}`, - this.g.user.wallets[0].inkey - ) - const addressData = mapAddressesData(data) - - addressData.note = `Shared on ${currentDateTime()}` - const lastAcctiveAddress = - this.addresses.data - .filter( - a => a.wallet === addressData.wallet && !a.isChange && a.hasActivity - ) - .pop() || {} - addressData.gapLimitExceeded = - !addressData.isChange && - addressData.addressIndex > - lastAcctiveAddress.addressIndex + - this.config.DEFAULT_RECEIVE_GAP_LIMIT - - this.openQrCodeDialog(addressData) - const wallet = this.walletAccounts.find(w => w.id === walletId) || {} - wallet.address_no = addressData.addressIndex - await this.refreshAddresses() - }, - - //################### ADDRESS HISTORY ################### - addressHistoryFromTxs: function (addressData, txs) { - const addressHistory = [] - txs.forEach(tx => { - const sent = tx.vin - .filter( - vin => vin.prevout.scriptpubkey_address === addressData.address - ) - .map(vin => mapInputToSentHistory(tx, addressData, vin)) - - const received = tx.vout - .filter(vout => vout.scriptpubkey_address === addressData.address) - .map(vout => mapOutputToReceiveHistory(tx, addressData, vout)) - addressHistory.push(...sent, ...received) - }) - return addressHistory - }, - getFilteredAddressesHistory: function () { - return this.addresses.history.filter( - a => (!a.isChange || a.sent) && !a.isSubItem - ) - }, - exportHistoryToCSV: function () { - const history = this.getFilteredAddressesHistory().map(a => ({ - ...a, - action: a.sent ? 'Sent' : 'Received' - })) - LNbits.utils.exportCSV( - this.historyTable.exportColums, - history, - 'address-history' - ) - }, - markSameTxAddressHistory: function () { - this.addresses.history - .filter(s => s.sent) - .forEach((el, i, arr) => { - if (el.isSubItem) return - - const sameTxItems = arr.slice(i + 1).filter(e => e.txId === el.txId) - if (!sameTxItems.length) return - sameTxItems.forEach(e => { - e.isSubItem = true - }) - - el.totalAmount = - el.amount + sameTxItems.reduce((t, e) => (t += e.amount || 0), 0) - el.sameTxItems = sameTxItems - }) - }, - showAddressHistoryDetails: function (addressHistory) { - addressHistory.expanded = true - }, - - //################### PAYMENT ################### - createTx: function (excludeChange = false) { - const tx = { - fee_rate: this.payment.feeRate, - tx_size: this.payment.txSize, - masterpubs: this.walletAccounts.map(w => ({ - public_key: w.masterpub, - fingerprint: w.fingerprint - })) - } - tx.inputs = this.utxos.data - .filter(utxo => utxo.selected) - .map(mapUtxoToPsbtInput) - .sort((a, b) => - a.tx_id < b.tx_id ? -1 : a.tx_id > b.tx_id ? 1 : a.vout - b.vout - ) - - tx.outputs = this.payment.data.map(out => ({ - address: out.address, - amount: out.amount - })) - - if (excludeChange) { - this.payment.changeAmount = 0 - } else { - const change = this.createChangeOutput() - this.payment.changeAmount = change.amount - if (change.amount >= this.DUST_LIMIT) { - tx.outputs.push(change) - } - } - // Only sort by amount on UI level (no lib for address decode) - // Should sort by scriptPubKey (as byte array) on the backend - tx.outputs.sort((a, b) => a.amount - b.amount) - - return tx - }, - createChangeOutput: function () { - const change = this.payment.changeAddress - const fee = this.payment.feeRate * this.payment.txSize - const inputAmount = this.getTotalSelectedUtxoAmount() - const payedAmount = this.getTotalPaymentAmount() - const walletAcount = - this.walletAccounts.find(w => w.id === change.wallet) || {} - + // components: ['my-checkbox'], + data: function () { return { - address: change.address, - amount: inputAmount - payedAmount - fee, - addressIndex: change.addressIndex, - addressIndex: change.addressIndex, - masterpub_fingerprint: walletAcount.fingerprint + DUST_LIMIT: 546, + filter: '', + + scan: { + scanning: false, + scanCount: 0, + scanIndex: 0 + }, + + currentAddress: null, + + tab: 'addresses', + + config: { + data: { + mempool_endpoint: 'https://mempool.space', + receive_gap_limit: 20, + change_gap_limit: 5 + }, + DEFAULT_RECEIVE_GAP_LIMIT: 20, + show: false + }, + + serial: { + selectedPort: null, + writableStreamClosed: null, + writer: null, + readableStreamClosed: null, + reader: null, + showAdvancedConfig: false, + receivedData: '', + config: {} + }, + + hww: { + password: null, + showPassword: false, + mnemonic: null, + showMnemonic: false, + authenticated: false, + showPasswordDialog: false, + showWipeDialog: false, + showRestoreDialog: false, + showConsole: false, + showSignedPsbt: false, + sendingPsbt: false, + signingPsbt: false, + psbtSent: false + }, + + formDialog: { + show: false, + data: {} + }, + + qrCodeDialog: { + show: false, + data: null + }, + ...tables, + ...tableData } }, - computeFee: function () { - const tx = this.createTx() - this.payment.txSize = Math.round(txSize(tx)) - return this.payment.feeRate * this.payment.txSize - }, - deletePaymentAddress: function (v) { - const index = this.payment.data.indexOf(v) - if (index !== -1) { - this.payment.data.splice(index, 1) - } - }, - initPaymentData: async function () { - if (!this.payment.show) return - await this.refreshAddresses() - this.payment.showAdvanced = false - this.payment.changeWallet = this.walletAccounts[0] - this.selectChangeAddress(this.payment.changeWallet) - - await this.refreshRecommendedFees() - this.payment.feeRate = this.payment.recommededFees.halfHourFee - }, - getFeeRateLabel: function (feeRate) { - const fees = this.payment.recommededFees - if (feeRate >= fees.fastestFee) return `High Priority (${feeRate} sat/vB)` - if (feeRate >= fees.halfHourFee) - return `Medium Priority (${feeRate} sat/vB)` - if (feeRate >= fees.hourFee) return `Low Priority (${feeRate} sat/vB)` - return `No Priority (${feeRate} sat/vB)` - }, - addPaymentAddress: function () { - this.payment.data.push({address: '', amount: undefined}) - }, - getTotalPaymentAmount: function () { - return this.payment.data.reduce((t, a) => t + (a.amount || 0), 0) - }, - selectChangeAddress: function (wallet = {}) { - this.payment.changeAddress = - this.addresses.data.find( - a => a.wallet === wallet.id && a.isChange && !a.hasActivity - ) || {} - }, - goToPaymentView: async function () { - this.payment.show = true - this.tab = 'utxos' - await this.initPaymentData() - }, - sendMaxToAddress: function (paymentAddress = {}) { - paymentAddress.amount = 0 - const tx = this.createTx(true) - this.payment.txSize = Math.round(txSize(tx)) - const fee = this.payment.feeRate * this.payment.txSize - const inputAmount = this.getTotalSelectedUtxoAmount() - const payedAmount = this.getTotalPaymentAmount() - paymentAddress.amount = Math.max(0, inputAmount - payedAmount - fee) - }, - //################### PSBT ################### - createPsbt: async function () { - const wallet = this.g.user.wallets[0] - try { - this.computeFee() - const tx = this.createTx() - txSize(tx) - for (const input of tx.inputs) { - input.tx_hex = await this.fetchTxHex(input.tx_id) + methods: { + //################### CONFIG ################### + getConfig: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/config', + this.g.user.wallets[0].adminkey + ) + this.config.data = data + } catch (error) { + LNbits.utils.notifyApiError(error) } - - this.payment.tx = tx - const {data} = await LNbits.api.request( - 'POST', - '/watchonly/api/v1/psbt', - wallet.adminkey, - tx - ) - - this.payment.psbtBase64 = data - } catch (err) { - LNbits.utils.notifyApiError(err) - } - }, - extractTxFromPsbt: async function (psbtBase64) { - const wallet = this.g.user.wallets[0] - try { - const {data} = await LNbits.api.request( - 'PUT', - '/watchonly/api/v1/psbt/extract', - wallet.adminkey, - { - psbtBase64, - inputs: this.payment.tx.inputs - } - ) - return data - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Cannot finalize PSBT!', - timeout: 10000 - }) - LNbits.utils.notifyApiError(error) - } - }, - updateSignedPsbt: async function (value) { - this.payment.psbtBase64Signed = value - - const data = await this.extractTxFromPsbt(this.payment.psbtBase64Signed) - if (data) { - this.payment.signedTx = JSON.parse(data.tx_json) - this.payment.signedTxHex = data.tx_hex - } else { - this.payment.signedTx = null - this.payment.signedTxHex = null - } - }, - broadcastTransaction: async function () { - try { + }, + updateConfig: async function () { const wallet = this.g.user.wallets[0] - const {data} = await LNbits.api.request( - 'POST', - '/watchonly/api/v1/tx', - wallet.adminkey, - {tx_hex: this.payment.signedTxHex} - ) - this.payment.sentTxId = data + try { + await LNbits.api.request( + 'PUT', + '/watchonly/api/v1/config', + wallet.adminkey, + this.config.data + ) + this.config.show = false + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, - this.$q.notify({ - type: 'positive', - message: 'Transaction broadcasted!', - caption: `${data}`, - timeout: 10000 - }) + //################### WALLETS ################### + getWalletName: function (walletId) { + const wallet = this.walletAccounts.find(wl => wl.id === walletId) + return wallet ? wallet.title : 'unknown' + }, + addWalletAccount: async function () { + const wallet = this.g.user.wallets[0] + const data = _.omit(this.formDialog.data, 'wallet') + await this.createWalletAccount(wallet, data) + }, + createWalletAccount: async function (wallet, data) { + try { + const response = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/wallet', + wallet.adminkey, + data + ) + this.walletAccounts.push(mapWalletAccount(response.data)) + this.formDialog.show = false - this.hww.psbtSent = false - this.payment.psbtBase64Signed = null - this.payment.signedTxHex = null - this.payment.signedTx = null - this.payment.psbtBase64 = null + await this.refreshWalletAccounts() + await this.refreshAddresses() - await this.scanAddressWithAmount() - } catch (error) { - this.payment.sentTxId = null - this.$q.notify({ - type: 'warning', - message: 'Failed to broadcast!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - //################### SERIAL PORT ################### - checkSerialPortSupported: function () { - if (!navigator.serial) { - this.$q.notify({ - type: 'warning', - message: 'Serial port communication not supported!', - caption: - 'Make sure your browser supports Serial Port and that you are using HTTPS.', - timeout: 10000 - }) - return false - } - return true - }, - openSerialPort: async function () { - if (!this.checkSerialPortSupported()) return - console.log('### openSerialPort', this.serial.selectedPort) - try { - navigator.serial.addEventListener('connect', event => { - console.log('### navigator.serial event: connected!', event) - }) - - navigator.serial.addEventListener('disconnect', () => { - this.hww.authenticated = false + if (!this.payment.changeWallett) { + this.payment.changeWallet = this.walletAccounts[0] + this.selectChangeAddress(this.payment.changeWallet) + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + deleteWalletAccount: function (walletAccountId) { + LNbits.utils + .confirmDialog( + 'Are you sure you want to delete this watch only wallet?' + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/watchonly/api/v1/wallet/' + walletAccountId, + this.g.user.wallets[0].adminkey + ) + this.walletAccounts = _.reject(this.walletAccounts, function ( + obj + ) { + return obj.id === walletAccountId + }) + await this.refreshWalletAccounts() + await this.refreshAddresses() + if ( + this.payment.changeWallet && + this.payment.changeWallet.id === walletAccountId + ) { + this.payment.changeWallet = this.walletAccounts[0] + this.selectChangeAddress(this.payment.changeWallet) + } + await this.scanAddressWithAmount() + } catch (error) { + this.$q.notify({ + type: 'warning', + message: + 'Error while deleting wallet account. Please try again.', + timeout: 10000 + }) + } + }) + }, + getAddressesForWallet: async function (walletId) { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/addresses/' + walletId, + this.g.user.wallets[0].inkey + ) + return data.map(mapAddressesData) + } catch (err) { this.$q.notify({ type: 'warning', - message: 'Disconnected from Serial Port!', + message: `Failed to fetch addresses for wallet with id ${walletId}.`, timeout: 10000 }) - }) - this.serial.selectedPort = await navigator.serial.requestPort() - // Wait for the serial port to open. - await this.serial.selectedPort.open({baudRate: 9600}) - this.startSerialPortReading() - - const textEncoder = new TextEncoderStream() - this.serial.writableStreamClosed = textEncoder.readable.pipeTo( - this.serial.selectedPort.writable - ) - - this.serial.writer = textEncoder.writable.getWriter() - } catch (error) { - this.serial.selectedPort = null - this.$q.notify({ - type: 'warning', - message: 'Cannot open serial port!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - closeSerialPort: async function () { - try { - console.log('### closeSerialPort', this.serial.selectedPort) - if (this.serial.writer) this.serial.writer.close() - if (this.serial.writableStreamClosed) - await this.serial.writableStreamClosed - if (this.serial.reader) this.serial.reader.cancel() - if (this.serial.readableStreamClosed) - await this.serial.readableStreamClosed.catch(() => { - /* Ignore the error */ - }) - if (this.serial.selectedPort) await this.serial.selectedPort.close() - this.serial.selectedPort = null - this.$q.notify({ - type: 'positive', - message: 'Serial port disconnected!', - timeout: 5000 - }) - } catch (error) { - this.serial.selectedPort = null - console.log('### error', error) - this.$q.notify({ - type: 'warning', - message: 'Cannot close serial port!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - - startSerialPortReading: async function () { - const port = this.serial.selectedPort - - while (port && port.readable) { - const textDecoder = new TextDecoderStream() - this.serial.readableStreamClosed = port.readable.pipeTo( - textDecoder.writable - ) - this.serial.reader = textDecoder.readable.getReader() - const readStringUntil = readFromSerialPort(this.serial) - + LNbits.utils.notifyApiError(err) + } + return [] + }, + getWatchOnlyWallets: async function () { try { - while (true) { - const {value, done} = await readStringUntil('\n') - console.log('### value', value) - if (value) { - this.handleSerialPortResponse(value) - this.updateSerialPortConsole(value) - } - console.log('### startSerialPortReading DONE', done) - if (done) return - } + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/wallet', + this.g.user.wallets[0].inkey + ) + return data } catch (error) { this.$q.notify({ type: 'warning', - message: 'Serial port communication error!', + message: 'Failed to fetch wallets.', + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + } + return [] + }, + refreshWalletAccounts: async function () { + const wallets = await this.getWatchOnlyWallets() + this.walletAccounts = wallets.map(w => mapWalletAccount(w)) + }, + getAmmountForWallet: function (walletId) { + const amount = this.addresses.data + .filter(a => a.wallet === walletId) + .reduce((t, a) => t + a.amount || 0, 0) + return this.satBtc(amount) + }, + + //################### ADDRESSES ################### + + refreshAddresses: async function () { + const wallets = await this.getWatchOnlyWallets() + this.addresses.data = [] + for (const {id, type} of wallets) { + const newAddresses = await this.getAddressesForWallet(id) + const uniqueAddresses = newAddresses.filter( + newAddr => + !this.addresses.data.find(a => a.address === newAddr.address) + ) + + const lastAcctiveAddress = + uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() || + {} + + uniqueAddresses.forEach(a => { + a.expanded = false + a.accountType = type + a.gapLimitExceeded = + !a.isChange && + a.addressIndex > + lastAcctiveAddress.addressIndex + + this.config.DEFAULT_RECEIVE_GAP_LIMIT + }) + this.addresses.data.push(...uniqueAddresses) + } + }, + updateAmountForAddress: async function (addressData, amount = 0) { + try { + const wallet = this.g.user.wallets[0] + addressData.amount = amount + if (!addressData.isChange) { + const addressWallet = this.walletAccounts.find( + w => w.id === addressData.wallet + ) + if ( + addressWallet && + addressWallet.address_no < addressData.addressIndex + ) { + addressWallet.address_no = addressData.addressIndex + } + } + + await LNbits.api.request( + 'PUT', + `/watchonly/api/v1/address/${addressData.id}`, + wallet.adminkey, + {amount} + ) + } catch (err) { + addressData.error = 'Failed to refresh amount for address' + this.$q.notify({ + type: 'warning', + message: `Failed to refresh amount for address ${addressData.address}`, + timeout: 10000 + }) + LNbits.utils.notifyApiError(err) + } + }, + updateNoteForAddress: async function (addressData, note) { + try { + const wallet = this.g.user.wallets[0] + await LNbits.api.request( + 'PUT', + `/watchonly/api/v1/address/${addressData.id}`, + wallet.adminkey, + {note: addressData.note} + ) + const updatedAddress = + this.addresses.data.find(a => a.id === addressData.id) || {} + updatedAddress.note = note + } catch (err) { + LNbits.utils.notifyApiError(err) + } + }, + getFilteredAddresses: function () { + const selectedWalletId = this.addresses.selectedWallet?.id + const filter = this.addresses.filterValues || [] + const includeChangeAddrs = filter.includes('Show Change Addresses') + const includeGapAddrs = filter.includes('Show Gap Addresses') + const excludeNoAmount = filter.includes('Only With Amount') + + const walletsLimit = this.walletAccounts.reduce((r, w) => { + r[`_${w.id}`] = w.address_no + return r + }, {}) + + const addresses = this.addresses.data.filter( + a => + (includeChangeAddrs || !a.isChange) && + (includeGapAddrs || + a.isChange || + a.addressIndex <= walletsLimit[`_${a.wallet}`]) && + !(excludeNoAmount && a.amount === 0) && + (!selectedWalletId || a.wallet === selectedWalletId) + ) + return addresses + }, + openGetFreshAddressDialog: async function (walletId) { + const {data} = await LNbits.api.request( + 'GET', + `/watchonly/api/v1/address/${walletId}`, + this.g.user.wallets[0].inkey + ) + const addressData = mapAddressesData(data) + + addressData.note = `Shared on ${currentDateTime()}` + const lastAcctiveAddress = + this.addresses.data + .filter( + a => + a.wallet === addressData.wallet && !a.isChange && a.hasActivity + ) + .pop() || {} + addressData.gapLimitExceeded = + !addressData.isChange && + addressData.addressIndex > + lastAcctiveAddress.addressIndex + + this.config.DEFAULT_RECEIVE_GAP_LIMIT + + this.openQrCodeDialog(addressData) + const wallet = this.walletAccounts.find(w => w.id === walletId) || {} + wallet.address_no = addressData.addressIndex + await this.refreshAddresses() + }, + + //################### ADDRESS HISTORY ################### + addressHistoryFromTxs: function (addressData, txs) { + const addressHistory = [] + txs.forEach(tx => { + const sent = tx.vin + .filter( + vin => vin.prevout.scriptpubkey_address === addressData.address + ) + .map(vin => mapInputToSentHistory(tx, addressData, vin)) + + const received = tx.vout + .filter(vout => vout.scriptpubkey_address === addressData.address) + .map(vout => mapOutputToReceiveHistory(tx, addressData, vout)) + addressHistory.push(...sent, ...received) + }) + return addressHistory + }, + getFilteredAddressesHistory: function () { + return this.addresses.history.filter( + a => (!a.isChange || a.sent) && !a.isSubItem + ) + }, + exportHistoryToCSV: function () { + const history = this.getFilteredAddressesHistory().map(a => ({ + ...a, + action: a.sent ? 'Sent' : 'Received' + })) + LNbits.utils.exportCSV( + this.historyTable.exportColums, + history, + 'address-history' + ) + }, + markSameTxAddressHistory: function () { + this.addresses.history + .filter(s => s.sent) + .forEach((el, i, arr) => { + if (el.isSubItem) return + + const sameTxItems = arr.slice(i + 1).filter(e => e.txId === el.txId) + if (!sameTxItems.length) return + sameTxItems.forEach(e => { + e.isSubItem = true + }) + + el.totalAmount = + el.amount + sameTxItems.reduce((t, e) => (t += e.amount || 0), 0) + el.sameTxItems = sameTxItems + }) + }, + showAddressHistoryDetails: function (addressHistory) { + addressHistory.expanded = true + }, + + //################### PAYMENT ################### + createTx: function (excludeChange = false) { + const tx = { + fee_rate: this.payment.feeRate, + tx_size: this.payment.txSize, + masterpubs: this.walletAccounts.map(w => ({ + public_key: w.masterpub, + fingerprint: w.fingerprint + })) + } + tx.inputs = this.utxos.data + .filter(utxo => utxo.selected) + .map(mapUtxoToPsbtInput) + .sort((a, b) => + a.tx_id < b.tx_id ? -1 : a.tx_id > b.tx_id ? 1 : a.vout - b.vout + ) + + tx.outputs = this.payment.data.map(out => ({ + address: out.address, + amount: out.amount + })) + + if (excludeChange) { + this.payment.changeAmount = 0 + } else { + const change = this.createChangeOutput() + this.payment.changeAmount = change.amount + if (change.amount >= this.DUST_LIMIT) { + tx.outputs.push(change) + } + } + // Only sort by amount on UI level (no lib for address decode) + // Should sort by scriptPubKey (as byte array) on the backend + tx.outputs.sort((a, b) => a.amount - b.amount) + + return tx + }, + createChangeOutput: function () { + const change = this.payment.changeAddress + const fee = this.payment.feeRate * this.payment.txSize + const inputAmount = this.getTotalSelectedUtxoAmount() + const payedAmount = this.getTotalPaymentAmount() + const walletAcount = + this.walletAccounts.find(w => w.id === change.wallet) || {} + + return { + address: change.address, + amount: inputAmount - payedAmount - fee, + addressIndex: change.addressIndex, + addressIndex: change.addressIndex, + masterpub_fingerprint: walletAcount.fingerprint + } + }, + computeFee: function () { + const tx = this.createTx() + this.payment.txSize = Math.round(txSize(tx)) + return this.payment.feeRate * this.payment.txSize + }, + deletePaymentAddress: function (v) { + const index = this.payment.data.indexOf(v) + if (index !== -1) { + this.payment.data.splice(index, 1) + } + }, + initPaymentData: async function () { + if (!this.payment.show) return + await this.refreshAddresses() + + this.payment.showAdvanced = false + this.payment.changeWallet = this.walletAccounts[0] + this.selectChangeAddress(this.payment.changeWallet) + + await this.refreshRecommendedFees() + this.payment.feeRate = this.payment.recommededFees.halfHourFee + }, + getFeeRateLabel: function (feeRate) { + const fees = this.payment.recommededFees + if (feeRate >= fees.fastestFee) + return `High Priority (${feeRate} sat/vB)` + if (feeRate >= fees.halfHourFee) + return `Medium Priority (${feeRate} sat/vB)` + if (feeRate >= fees.hourFee) return `Low Priority (${feeRate} sat/vB)` + return `No Priority (${feeRate} sat/vB)` + }, + addPaymentAddress: function () { + this.payment.data.push({address: '', amount: undefined}) + }, + getTotalPaymentAmount: function () { + return this.payment.data.reduce((t, a) => t + (a.amount || 0), 0) + }, + selectChangeAddress: function (wallet = {}) { + this.payment.changeAddress = + this.addresses.data.find( + a => a.wallet === wallet.id && a.isChange && !a.hasActivity + ) || {} + }, + goToPaymentView: async function () { + this.payment.show = true + this.tab = 'utxos' + await this.initPaymentData() + }, + sendMaxToAddress: function (paymentAddress = {}) { + paymentAddress.amount = 0 + const tx = this.createTx(true) + this.payment.txSize = Math.round(txSize(tx)) + const fee = this.payment.feeRate * this.payment.txSize + const inputAmount = this.getTotalSelectedUtxoAmount() + const payedAmount = this.getTotalPaymentAmount() + paymentAddress.amount = Math.max(0, inputAmount - payedAmount - fee) + }, + //################### PSBT ################### + createPsbt: async function () { + const wallet = this.g.user.wallets[0] + try { + this.computeFee() + const tx = this.createTx() + txSize(tx) + for (const input of tx.inputs) { + input.tx_hex = await this.fetchTxHex(input.tx_id) + } + + this.payment.tx = tx + const {data} = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/psbt', + wallet.adminkey, + tx + ) + + this.payment.psbtBase64 = data + } catch (err) { + LNbits.utils.notifyApiError(err) + } + }, + extractTxFromPsbt: async function (psbtBase64) { + const wallet = this.g.user.wallets[0] + try { + const {data} = await LNbits.api.request( + 'PUT', + '/watchonly/api/v1/psbt/extract', + wallet.adminkey, + { + psbtBase64, + inputs: this.payment.tx.inputs + } + ) + return data + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Cannot finalize PSBT!', + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + } + }, + updateSignedPsbt: async function (value) { + this.payment.psbtBase64Signed = value + + const data = await this.extractTxFromPsbt(this.payment.psbtBase64Signed) + if (data) { + this.payment.signedTx = JSON.parse(data.tx_json) + this.payment.signedTxHex = data.tx_hex + } else { + this.payment.signedTx = null + this.payment.signedTxHex = null + } + }, + broadcastTransaction: async function () { + try { + const wallet = this.g.user.wallets[0] + const {data} = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/tx', + wallet.adminkey, + {tx_hex: this.payment.signedTxHex} + ) + this.payment.sentTxId = data + + this.$q.notify({ + type: 'positive', + message: 'Transaction broadcasted!', + caption: `${data}`, + timeout: 10000 + }) + + this.hww.psbtSent = false + this.payment.psbtBase64Signed = null + this.payment.signedTxHex = null + this.payment.signedTx = null + this.payment.psbtBase64 = null + + await this.scanAddressWithAmount() + } catch (error) { + this.payment.sentTxId = null + this.$q.notify({ + type: 'warning', + message: 'Failed to broadcast!', caption: `${error}`, timeout: 10000 }) } - } - console.log('### startSerialPortReading port', port) - }, - - handleSerialPortResponse: function (value) { - const msg = value.split(' ') - if (msg[0] == COMMAND_SIGN_PSBT) this.handleSignResponse(msg[1]) - else if (msg[0] == COMMAND_PASSWORD) this.handleLoginResponse(msg[1]) - else if (msg[0] == COMMAND_PASSWORD_CLEAR) - this.handleLogoutResponse(msg[1]) - else if (msg[0] == COMMAND_SEND_PSBT) this.handleSendPsbtResponse(msg[1]) - else if (msg[0] == COMMAND_WIPE) this.handleWipeResponse(msg[1]) - else console.log('### handleSerialPortResponse', value) - }, - updateSerialPortConsole: function (value) { - this.serial.receivedData += value + '\n' - const textArea = document.getElementById( - 'watchonly-serial-port-data-input' - ) - if (textArea) textArea.scrollTop = textArea.scrollHeight - }, - sharePsbtWithAnimatedQRCode: async function () { - console.log('### sharePsbtWithAnimatedQRCode') - }, - //################### HARDWARE WALLET ################### - hwwShowPasswordDialog: async function () { - try { - this.hww.showPasswordDialog = true - await this.serial.writer.write(COMMAND_PASSWORD + '\n') - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to connect to Hardware Wallet!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - hwwShowWipeDialog: async function () { - try { - this.hww.showWipeDialog = true - await this.serial.writer.write(COMMAND_WIPE + '\n') - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to connect to Hardware Wallet!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - hwwShowRestoreDialog: async function () { - try { - this.hww.showRestoreDialog = true - await this.serial.writer.write(COMMAND_WIPE + '\n') - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to connect to Hardware Wallet!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - hwwLogin: async function () { - try { - await this.serial.writer.write( - COMMAND_PASSWORD + ' ' + this.hww.password + '\n' - ) - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to send password to Hardware Wallet!', - caption: `${error}`, - timeout: 10000 - }) - } finally { - this.hww.showPasswordDialog = false - this.hww.password = null - this.hww.showPassword = false - } - }, - handleLoginResponse: function (res = '') { - this.hww.authenticated = res.trim() === '1' - if (this.hww.authenticated) { - this.$q.notify({ - type: 'positive', - message: 'Login successfull!', - timeout: 10000 - }) - } else { - this.$q.notify({ - type: 'warning', - message: 'Wrong password, try again!', - timeout: 10000 - }) - } - }, - hwwLogout: async function () { - try { - await this.serial.writer.write(COMMAND_PASSWORD_CLEAR + '\n') - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to logout from Hardware Wallet!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - handleLogoutResponse: function (res = '') { - this.hww.authenticated = !(res.trim() === '1') - if (this.hww.authenticated) { - this.$q.notify({ - type: 'warning', - message: 'Failed to logout from Hardware Wallet', - timeout: 10000 - }) - } - }, - hwwExecuteDefaultCommand: function () { - if (this.hww.authenticated) { - this.hwwSendPsbt() - } else { - this.hwwShowPasswordDialog() - } - }, - hwwSendPsbt: async function () { - try { - this.hww.sendingPsbt = true - await this.serial.writer.write( - COMMAND_SEND_PSBT + ' ' + this.payment.psbtBase64 + '\n' - ) - this.$q.notify({ - type: 'positive', - message: 'Data sent to serial port device!', - timeout: 5000 - }) - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to send data to serial port!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - handleSendPsbtResponse: function (res = '') { - this.hww.psbtSent = true - this.hww.sendingPsbt = false - }, - hwwSignPsbt: async function () { - try { - this.hww.signingPsbt = true - await this.serial.writer.write(COMMAND_SIGN_PSBT + '\n') - this.$q.notify({ - type: 'positive', - message: 'PSBT signed!', - timeout: 5000 - }) - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to sign PSBT!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - handleSignResponse: function (res = '') { - this.hww.signingPsbt = false - this.updateSignedPsbt(res) - if (this.hww.authenticated) { - this.$q.notify({ - type: 'positive', - message: 'Transaction Signed', - timeout: 10000 - }) - } - }, - hwwHelp: async function () { - try { - await this.serial.writer.write(COMMAND_HELP + '\n') - this.$q.notify({ - type: 'positive', - message: 'Check display or console for details!', - timeout: 5000 - }) - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to ask for help!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - hwwWipe: async function () { - try { - this.hww.showWipeDialog = false - await this.serial.writer.write( - COMMAND_WIPE + ' ' + this.hww.password + '\n' - ) - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to ask for help!', - caption: `${error}`, - timeout: 10000 - }) - } finally { - this.hww.password = null - this.hww.showPassword = false - } - }, - handleWipeResponse: function (res = '') { - const wiped = res.trim() === '1' - console.log('### wiped', wiped) - if (wiped) { - this.$q.notify({ - type: 'positive', - message: 'Wallet wiped!', - timeout: 10000 - }) - } else { - this.$q.notify({ - type: 'warning', - message: 'Failed to wipe wallet!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - hwwShowSeed: async function () { - try { - await this.serial.writer.write(COMMAND_SEED + '\n') - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to show seed!', - caption: `${error}`, - timeout: 10000 - }) - } - }, - hwwRestore: async function () { - try { - await this.serial.writer.write( - COMMAND_RESTORE + ' ' + this.hww.mnemonic + '\n' - ) - await this.serial.writer.write( - COMMAND_PASSWORD + ' ' + this.hww.password + '\n' - ) - } catch (error) { - this.$q.notify({ - type: 'warning', - message: 'Failed to restore from seed!', - caption: `${error}`, - timeout: 10000 - }) - } finally { - this.hww.showRestoreDialog = false - this.hww.mnemonic = null - this.hww.showMnemonic = false - this.hww.password = null - this.hww.showPassword = false - } - }, - //################### UTXOs ################### - scanAllAddresses: async function () { - await this.refreshAddresses() - this.addresses.history = [] - let addresses = this.addresses.data - this.utxos.data = [] - this.utxos.total = 0 - // Loop while new funds are found on the gap adresses. - // Use 1000 limit as a safety check (scan 20 000 addresses max) - for (let i = 0; i < 1000 && addresses.length; i++) { - await this.updateUtxosForAddresses(addresses) - const oldAddresses = this.addresses.data.slice() - await this.refreshAddresses() - const newAddresses = this.addresses.data.slice() - // check if gap addresses have been extended - addresses = newAddresses.filter( - newAddr => !oldAddresses.find(oldAddr => oldAddr.id === newAddr.id) - ) - if (addresses.length) { + }, + //################### SERIAL PORT ################### + checkSerialPortSupported: function () { + if (!navigator.serial) { this.$q.notify({ - type: 'positive', - message: 'Funds found! Scanning for more...', + type: 'warning', + message: 'Serial port communication not supported!', + caption: + 'Make sure your browser supports Serial Port and that you are using HTTPS.', + timeout: 10000 + }) + return false + } + return true + }, + openSerialPort: async function () { + if (!this.checkSerialPortSupported()) return + console.log('### openSerialPort', this.serial.selectedPort) + try { + navigator.serial.addEventListener('connect', event => { + console.log('### navigator.serial event: connected!', event) + }) + + navigator.serial.addEventListener('disconnect', () => { + this.hww.authenticated = false + this.$q.notify({ + type: 'warning', + message: 'Disconnected from Serial Port!', + timeout: 10000 + }) + }) + this.serial.selectedPort = await navigator.serial.requestPort() + // Wait for the serial port to open. + await this.serial.selectedPort.open({baudRate: 9600}) + this.startSerialPortReading() + + const textEncoder = new TextEncoderStream() + this.serial.writableStreamClosed = textEncoder.readable.pipeTo( + this.serial.selectedPort.writable + ) + + this.serial.writer = textEncoder.writable.getWriter() + } catch (error) { + this.serial.selectedPort = null + this.$q.notify({ + type: 'warning', + message: 'Cannot open serial port!', + caption: `${error}`, timeout: 10000 }) } - } - }, - scanAddressWithAmount: async function () { - this.utxos.data = [] - this.utxos.total = 0 - this.addresses.history = [] - const addresses = this.addresses.data.filter(a => a.hasActivity) - await this.updateUtxosForAddresses(addresses) - }, - scanAddress: async function (addressData) { - this.updateUtxosForAddresses([addressData]) - this.$q.notify({ - type: 'positive', - message: 'Address Rescanned', - timeout: 10000 - }) - }, - updateUtxosForAddresses: async function (addresses = []) { - this.scan = {scanning: true, scanCount: addresses.length, scanIndex: 0} - - try { - for (addrData of addresses) { - const addressHistory = await this.getAddressTxsDelayed(addrData) - // remove old entries - this.addresses.history = this.addresses.history.filter( - h => h.address !== addrData.address - ) - - // add new entrie - this.addresses.history.push(...addressHistory) - this.addresses.history.sort((a, b) => - !a.height ? -1 : b.height - a.height - ) - this.markSameTxAddressHistory() - - if (addressHistory.length) { - // search only if it ever had any activity - const utxos = await this.getAddressTxsUtxoDelayed(addrData.address) - this.updateUtxosForAddress(addrData, utxos) - } - - this.scan.scanIndex++ + }, + closeSerialPort: async function () { + try { + console.log('### closeSerialPort', this.serial.selectedPort) + if (this.serial.writer) this.serial.writer.close() + if (this.serial.writableStreamClosed) + await this.serial.writableStreamClosed + if (this.serial.reader) this.serial.reader.cancel() + if (this.serial.readableStreamClosed) + await this.serial.readableStreamClosed.catch(() => { + /* Ignore the error */ + }) + if (this.serial.selectedPort) await this.serial.selectedPort.close() + this.serial.selectedPort = null + this.$q.notify({ + type: 'positive', + message: 'Serial port disconnected!', + timeout: 5000 + }) + } catch (error) { + this.serial.selectedPort = null + console.log('### error', error) + this.$q.notify({ + type: 'warning', + message: 'Cannot close serial port!', + caption: `${error}`, + timeout: 10000 + }) } - } catch (error) { - console.error(error) + }, + + startSerialPortReading: async function () { + const port = this.serial.selectedPort + + while (port && port.readable) { + const textDecoder = new TextDecoderStream() + this.serial.readableStreamClosed = port.readable.pipeTo( + textDecoder.writable + ) + this.serial.reader = textDecoder.readable.getReader() + const readStringUntil = readFromSerialPort(this.serial) + + try { + while (true) { + const {value, done} = await readStringUntil('\n') + console.log('### value', value) + if (value) { + this.handleSerialPortResponse(value) + this.updateSerialPortConsole(value) + } + console.log('### startSerialPortReading DONE', done) + if (done) return + } + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Serial port communication error!', + caption: `${error}`, + timeout: 10000 + }) + } + } + console.log('### startSerialPortReading port', port) + }, + + handleSerialPortResponse: function (value) { + const msg = value.split(' ') + if (msg[0] == COMMAND_SIGN_PSBT) this.handleSignResponse(msg[1]) + else if (msg[0] == COMMAND_PASSWORD) this.handleLoginResponse(msg[1]) + else if (msg[0] == COMMAND_PASSWORD_CLEAR) + this.handleLogoutResponse(msg[1]) + else if (msg[0] == COMMAND_SEND_PSBT) + this.handleSendPsbtResponse(msg[1]) + else if (msg[0] == COMMAND_WIPE) this.handleWipeResponse(msg[1]) + else console.log('### handleSerialPortResponse', value) + }, + updateSerialPortConsole: function (value) { + this.serial.receivedData += value + '\n' + const textArea = document.getElementById( + 'watchonly-serial-port-data-input' + ) + if (textArea) textArea.scrollTop = textArea.scrollHeight + }, + sharePsbtWithAnimatedQRCode: async function () { + console.log('### sharePsbtWithAnimatedQRCode') + }, + //################### HARDWARE WALLET ################### + hwwShowPasswordDialog: async function () { + try { + this.hww.showPasswordDialog = true + await this.serial.writer.write(COMMAND_PASSWORD + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to connect to Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwShowWipeDialog: async function () { + try { + this.hww.showWipeDialog = true + await this.serial.writer.write(COMMAND_WIPE + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to connect to Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwShowRestoreDialog: async function () { + try { + this.hww.showRestoreDialog = true + await this.serial.writer.write(COMMAND_WIPE + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to connect to Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwLogin: async function () { + try { + await this.serial.writer.write( + COMMAND_PASSWORD + ' ' + this.hww.password + '\n' + ) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to send password to Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.hww.showPasswordDialog = false + this.hww.password = null + this.hww.showPassword = false + } + }, + handleLoginResponse: function (res = '') { + this.hww.authenticated = res.trim() === '1' + if (this.hww.authenticated) { + this.$q.notify({ + type: 'positive', + message: 'Login successfull!', + timeout: 10000 + }) + } else { + this.$q.notify({ + type: 'warning', + message: 'Wrong password, try again!', + timeout: 10000 + }) + } + }, + hwwLogout: async function () { + try { + await this.serial.writer.write(COMMAND_PASSWORD_CLEAR + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to logout from Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleLogoutResponse: function (res = '') { + this.hww.authenticated = !(res.trim() === '1') + if (this.hww.authenticated) { + this.$q.notify({ + type: 'warning', + message: 'Failed to logout from Hardware Wallet', + timeout: 10000 + }) + } + }, + hwwExecuteDefaultCommand: function () { + if (this.hww.authenticated) { + this.hwwSendPsbt() + } else { + this.hwwShowPasswordDialog() + } + }, + hwwSendPsbt: async function () { + try { + this.hww.sendingPsbt = true + await this.serial.writer.write( + COMMAND_SEND_PSBT + ' ' + this.payment.psbtBase64 + '\n' + ) + this.$q.notify({ + type: 'positive', + message: 'Data sent to serial port device!', + timeout: 5000 + }) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to send data to serial port!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleSendPsbtResponse: function (res = '') { + this.hww.psbtSent = true + this.hww.sendingPsbt = false + }, + hwwSignPsbt: async function () { + try { + this.hww.signingPsbt = true + await this.serial.writer.write(COMMAND_SIGN_PSBT + '\n') + this.$q.notify({ + type: 'positive', + message: 'PSBT signed!', + timeout: 5000 + }) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to sign PSBT!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleSignResponse: function (res = '') { + this.hww.signingPsbt = false + this.updateSignedPsbt(res) + if (this.hww.authenticated) { + this.$q.notify({ + type: 'positive', + message: 'Transaction Signed', + timeout: 10000 + }) + } + }, + hwwHelp: async function () { + try { + await this.serial.writer.write(COMMAND_HELP + '\n') + this.$q.notify({ + type: 'positive', + message: 'Check display or console for details!', + timeout: 5000 + }) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to ask for help!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwWipe: async function () { + try { + this.hww.showWipeDialog = false + await this.serial.writer.write( + COMMAND_WIPE + ' ' + this.hww.password + '\n' + ) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to ask for help!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.hww.password = null + this.hww.showPassword = false + } + }, + handleWipeResponse: function (res = '') { + const wiped = res.trim() === '1' + console.log('### wiped', wiped) + if (wiped) { + this.$q.notify({ + type: 'positive', + message: 'Wallet wiped!', + timeout: 10000 + }) + } else { + this.$q.notify({ + type: 'warning', + message: 'Failed to wipe wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwShowSeed: async function () { + try { + await this.serial.writer.write(COMMAND_SEED + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to show seed!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwRestore: async function () { + try { + await this.serial.writer.write( + COMMAND_RESTORE + ' ' + this.hww.mnemonic + '\n' + ) + await this.serial.writer.write( + COMMAND_PASSWORD + ' ' + this.hww.password + '\n' + ) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to restore from seed!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.hww.showRestoreDialog = false + this.hww.mnemonic = null + this.hww.showMnemonic = false + this.hww.password = null + this.hww.showPassword = false + } + }, + //################### UTXOs ################### + scanAllAddresses: async function () { + await this.refreshAddresses() + this.addresses.history = [] + let addresses = this.addresses.data + this.utxos.data = [] + this.utxos.total = 0 + // Loop while new funds are found on the gap adresses. + // Use 1000 limit as a safety check (scan 20 000 addresses max) + for (let i = 0; i < 1000 && addresses.length; i++) { + await this.updateUtxosForAddresses(addresses) + const oldAddresses = this.addresses.data.slice() + await this.refreshAddresses() + const newAddresses = this.addresses.data.slice() + // check if gap addresses have been extended + addresses = newAddresses.filter( + newAddr => !oldAddresses.find(oldAddr => oldAddr.id === newAddr.id) + ) + if (addresses.length) { + this.$q.notify({ + type: 'positive', + message: 'Funds found! Scanning for more...', + timeout: 10000 + }) + } + } + }, + scanAddressWithAmount: async function () { + this.utxos.data = [] + this.utxos.total = 0 + this.addresses.history = [] + const addresses = this.addresses.data.filter(a => a.hasActivity) + await this.updateUtxosForAddresses(addresses) + }, + scanAddress: async function (addressData) { + this.updateUtxosForAddresses([addressData]) this.$q.notify({ - type: 'warning', - message: 'Failed to scan addresses', + type: 'positive', + message: 'Address Rescanned', timeout: 10000 }) - } finally { - this.scan.scanning = false - } - }, - updateUtxosForAddress: function (addressData, utxos = []) { - const wallet = - this.walletAccounts.find(w => w.id === addressData.wallet) || {} + }, + updateUtxosForAddresses: async function (addresses = []) { + this.scan = {scanning: true, scanCount: addresses.length, scanIndex: 0} - const newUtxos = utxos.map(utxo => - mapAddressDataToUtxo(wallet, addressData, utxo) - ) - // remove old utxos - this.utxos.data = this.utxos.data.filter( - u => u.address !== addressData.address - ) - // add new utxos - this.utxos.data.push(...newUtxos) - if (utxos.length) { - this.utxos.data.sort((a, b) => b.sort - a.sort) - this.utxos.total = this.utxos.data.reduce( - (total, y) => (total += y?.amount || 0), + try { + for (addrData of addresses) { + const addressHistory = await this.getAddressTxsDelayed(addrData) + // remove old entries + this.addresses.history = this.addresses.history.filter( + h => h.address !== addrData.address + ) + + // add new entrie + this.addresses.history.push(...addressHistory) + this.addresses.history.sort((a, b) => + !a.height ? -1 : b.height - a.height + ) + this.markSameTxAddressHistory() + + if (addressHistory.length) { + // search only if it ever had any activity + const utxos = await this.getAddressTxsUtxoDelayed( + addrData.address + ) + this.updateUtxosForAddress(addrData, utxos) + } + + this.scan.scanIndex++ + } + } catch (error) { + console.error(error) + this.$q.notify({ + type: 'warning', + message: 'Failed to scan addresses', + timeout: 10000 + }) + } finally { + this.scan.scanning = false + } + }, + updateUtxosForAddress: function (addressData, utxos = []) { + const wallet = + this.walletAccounts.find(w => w.id === addressData.wallet) || {} + + const newUtxos = utxos.map(utxo => + mapAddressDataToUtxo(wallet, addressData, utxo) + ) + // remove old utxos + this.utxos.data = this.utxos.data.filter( + u => u.address !== addressData.address + ) + // add new utxos + this.utxos.data.push(...newUtxos) + if (utxos.length) { + this.utxos.data.sort((a, b) => b.sort - a.sort) + this.utxos.total = this.utxos.data.reduce( + (total, y) => (total += y?.amount || 0), + 0 + ) + } + const addressTotal = utxos.reduce( + (total, y) => (total += y?.value || 0), 0 ) - } - const addressTotal = utxos.reduce( - (total, y) => (total += y?.value || 0), - 0 - ) - this.updateAmountForAddress(addressData, addressTotal) - }, - getTotalSelectedUtxoAmount: function () { - const total = this.utxos.data - .filter(u => u.selected) - .reduce((t, a) => t + (a.amount || 0), 0) - return total - }, - applyUtxoSelectionMode: function () { - const payedAmount = this.getTotalPaymentAmount() - const mode = this.payment.utxoSelectionMode - this.utxos.data.forEach(u => (u.selected = false)) - const isManual = mode === 'Manual' - if (isManual || !payedAmount) return - - const isSelectAll = mode === 'Select All' - if (isSelectAll || payedAmount >= this.utxos.total) { - this.utxos.data.forEach(u => (u.selected = true)) - return - } - const isSmallerFirst = mode === 'Smaller Inputs First' - const isLargerFirst = mode === 'Larger Inputs First' - - let selectedUtxos = this.utxos.data.slice() - if (isSmallerFirst || isLargerFirst) { - const sortFn = isSmallerFirst - ? (a, b) => a.amount - b.amount - : (a, b) => b.amount - a.amount - selectedUtxos.sort(sortFn) - } else { - // default to random order - selectedUtxos = _.shuffle(selectedUtxos) - } - selectedUtxos.reduce((total, utxo) => { - utxo.selected = total < payedAmount - total += utxo.amount + this.updateAmountForAddress(addressData, addressTotal) + }, + getTotalSelectedUtxoAmount: function () { + const total = this.utxos.data + .filter(u => u.selected) + .reduce((t, a) => t + (a.amount || 0), 0) return total - }, 0) - }, + }, + applyUtxoSelectionMode: function () { + const payedAmount = this.getTotalPaymentAmount() + const mode = this.payment.utxoSelectionMode + this.utxos.data.forEach(u => (u.selected = false)) + const isManual = mode === 'Manual' + if (isManual || !payedAmount) return - //################### MEMPOOL API ################### - getAddressTxsDelayed: async function (addrData) { - const { - bitcoin: {addresses: addressesAPI} - } = mempoolJS({ - hostname: new URL(this.config.data.mempool_endpoint).hostname - }) + const isSelectAll = mode === 'Select All' + if (isSelectAll || payedAmount >= this.utxos.total) { + this.utxos.data.forEach(u => (u.selected = true)) + return + } + const isSmallerFirst = mode === 'Smaller Inputs First' + const isLargerFirst = mode === 'Larger Inputs First' - const fn = async () => - addressesAPI.getAddressTxs({ - address: addrData.address - }) - const addressTxs = await retryWithDelay(fn) - return this.addressHistoryFromTxs(addrData, addressTxs) - }, + let selectedUtxos = this.utxos.data.slice() + if (isSmallerFirst || isLargerFirst) { + const sortFn = isSmallerFirst + ? (a, b) => a.amount - b.amount + : (a, b) => b.amount - a.amount + selectedUtxos.sort(sortFn) + } else { + // default to random order + selectedUtxos = _.shuffle(selectedUtxos) + } + selectedUtxos.reduce((total, utxo) => { + utxo.selected = total < payedAmount + total += utxo.amount + return total + }, 0) + }, - refreshRecommendedFees: async function () { - const { - bitcoin: {fees: feesAPI} - } = mempoolJS({ - hostname: new URL(this.config.data.mempool_endpoint).hostname - }) + //################### MEMPOOL API ################### + getAddressTxsDelayed: async function (addrData) { + const { + bitcoin: {addresses: addressesAPI} + } = mempoolJS() - const fn = async () => feesAPI.getFeesRecommended() - this.payment.recommededFees = await retryWithDelay(fn) - }, - getAddressTxsUtxoDelayed: async function (address) { - const { - bitcoin: {addresses: addressesAPI} - } = mempoolJS({ - hostname: new URL(this.config.data.mempool_endpoint).hostname - }) + const fn = async () => + addressesAPI.getAddressTxs({ + address: addrData.address + }) + const addressTxs = await retryWithDelay(fn) + return this.addressHistoryFromTxs(addrData, addressTxs) + }, - const fn = async () => - addressesAPI.getAddressTxsUtxo({ - address - }) - return retryWithDelay(fn) - }, - fetchTxHex: async function (txId) { - const { - bitcoin: {transactions: transactionsAPI} - } = mempoolJS({ - hostname: new URL(this.config.data.mempool_endpoint).hostname - }) + refreshRecommendedFees: async function () { + const { + bitcoin: {fees: feesAPI} + } = mempoolJS() - try { - const response = await transactionsAPI.getTxHex({txid: txId}) - return response - } catch (error) { - this.$q.notify({ - type: 'warning', - message: `Failed to fetch transaction details for tx id: '${txId}'`, - timeout: 10000 - }) - LNbits.utils.notifyApiError(error) - throw error + const fn = async () => feesAPI.getFeesRecommended() + this.payment.recommededFees = await retryWithDelay(fn) + }, + getAddressTxsUtxoDelayed: async function (address) { + const { + bitcoin: {addresses: addressesAPI} + } = mempoolJS() + + const fn = async () => + addressesAPI.getAddressTxsUtxo({ + address + }) + return retryWithDelay(fn) + }, + fetchTxHex: async function (txId) { + const { + bitcoin: {transactions: transactionsAPI} + } = mempoolJS() + + try { + const response = await transactionsAPI.getTxHex({txid: txId}) + return response + } catch (error) { + this.$q.notify({ + type: 'warning', + message: `Failed to fetch transaction details for tx id: '${txId}'`, + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + throw error + } + }, + + //################### OTHER ################### + closeFormDialog: function () { + this.formDialog.data = { + is_unique: false + } + }, + openQrCodeDialog: function (addressData) { + this.currentAddress = addressData + this.addresses.note = addressData.note || '' + this.addresses.show = true + }, + searchInTab: function (tab, value) { + this.tab = tab + this[`${tab}Table`].filter = value + }, + + satBtc(val, showUnit = true) { + const value = this.config.data.sats_denominated + ? LNbits.utils.formatSat(val) + : val == 0 + ? 0.0 + : (val / 100000000).toFixed(8) + if (!showUnit) return value + return this.config.data.sats_denominated + ? value + ' sat' + : value + ' BTC' + }, + getAccountDescription: function (accountType) { + return getAccountDescription(accountType) } }, - - //################### OTHER ################### - closeFormDialog: function () { - this.formDialog.data = { - is_unique: false + created: async function () { + if (this.g.user.wallets.length) { + await this.getConfig() + await this.refreshWalletAccounts() + await this.refreshAddresses() + await this.scanAddressWithAmount() } - }, - openQrCodeDialog: function (addressData) { - this.currentAddress = addressData - this.addresses.note = addressData.note || '' - this.addresses.show = true - }, - searchInTab: function (tab, value) { - this.tab = tab - this[`${tab}Table`].filter = value - }, - - satBtc(val, showUnit = true) { - const value = this.config.data.sats_denominated - ? LNbits.utils.formatSat(val) - : val == 0 - ? 0.0 - : (val / 100000000).toFixed(8) - if (!showUnit) return value - return this.config.data.sats_denominated ? value + ' sat' : value + ' BTC' - }, - getAccountDescription: function (accountType) { - return getAccountDescription(accountType) } - }, - created: async function () { - if (this.g.user.wallets.length) { - await this.getConfig() - await this.refreshWalletAccounts() - await this.refreshAddresses() - await this.scanAddressWithAmount() - } - } -}) + }) +} +watchOnly() diff --git a/lnbits/extensions/watchonly/static/js/utils.js b/lnbits/extensions/watchonly/static/js/utils.js index 3a606fe4c..a4efc6076 100644 --- a/lnbits/extensions/watchonly/static/js/utils.js +++ b/lnbits/extensions/watchonly/static/js/utils.js @@ -139,3 +139,23 @@ const readFromSerialPort = serial => { } return readStringUntil } + +function loadTemplateAsync(path) { + const result = new Promise(resolve => { + const xhttp = new XMLHttpRequest() + console.log('### 300') + + xhttp.onreadystatechange = function () { + if (this.readyState == 4) { + if (this.status == 200) resolve(this.responseText) + + if (this.status == 404) resolve('Page not found.') + } + } + + xhttp.open('GET', path, true) + xhttp.send() + }) + + return result +} diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html index 8e9780438..00d816b2e 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/index.html +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -31,7 +31,9 @@ -
+
+ +
+ {% endblock %}