diff --git a/Makefile b/Makefile index 4aedb3d7..d91d0421 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ format: prettier isort black check: mypy checkprettier checkisort checkblack prettier: $(shell find lnbits -name "*.js" -name ".html") - ./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js + ./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html black: poetry run black . @@ -19,7 +19,7 @@ isort: poetry run isort . checkprettier: $(shell find lnbits -name "*.js" -name ".html") - ./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js + ./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html checkblack: poetry run black --check . diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md index be7bf351..c07f75b1 100644 --- a/lnbits/extensions/watchonly/README.md +++ b/lnbits/extensions/watchonly/README.md @@ -8,15 +8,16 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb ### Wallet Account - a user can add one or more `xPubs` or `descriptors` - - the `xPub` fingerprint must be unique per user + - the `xPub` must be unique per user - such and entry is called an `Wallet Account` - the addresses in a `Wallet Account` are split into `Receive Addresses` and `Change Address` - the user interacts directly only with the `Receive Addresses` (by sharing them) - see [BIP44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account-discovery) for more details - same `xPub` will always generate the same addresses (deterministic) - when a `Wallet Account` is created, there are generated `20 Receive Addresses` and `5 Change Address` - - the limits can be change from the `Config` page (see `screenshot 1`) + - the limits can be change from the `Config` page (see `screenshot 1`) - regular wallets only scan up to `20` empty receive addresses. If the user generates addresses beyond this limit a warning is shown (see `screenshot 4`) + - an account can be added `From Hardware Device` ### Scan Blockchain - when the user clicks `Scan Blockchain`, the wallet will loop over the all addresses (for each account) @@ -48,33 +49,32 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb - shows the UTXOs for all wallets - there can be multiple UTXOs for the same address -### Make Payment +### New Payment - create a new `Partially Signed Bitcoin Transaction` - multiple `Send Addresses` can be added - the `Max` button next to an address is for sending the remaining funds to this address (no change) - the user can select the inputs (UTXOs) manually, or it can use of the basic selection algorithms - amounts have to be provided for the `Send Addresses` beforehand (so the algorithm knows the amount to be selected) - - `Show Advanced` allows to (see `screenshot 2`): - - select from which account the change address will be selected (defaults to the first one) - - select the `Fee Rate` - - it defaults to the `Medium` value at the moment the `Make Payment` button was clicked - - it can be refreshed - - warnings are shown if the fee is too Low or to High + - `Show Change` allows to select from which account the change address will be selected (defaults to the first one) + - `Show Custom Fee` allows to manually select the fee + - it defaults to the `Medium` value at the moment the `New Payment` button was clicked + - it can be refreshed + - warnings are shown if the fee is too Low or to High -### Create PSBT - - based on the Inputs & Outputs selected by the user a PSBT will be generated - - this wallet is watch-only, therefore does not support signing - - it is not mandatory for the `Selected Amount` to be grater than `Payed Amount` - - the generated PSBT can be combined with other PSBTs that add more inputs. - - the generated PSBT can be imported for signing into different wallets like Electrum - - import the PSBT into Electrum and check the In/Outs/Fee (see `screenshot 3`) +### Check & Send + - creates the PSBT and sends it to the Hardware Wallet + - a confirmation will be shown for each Output and for the Fee + - after the user confirms the addresses and amounts, the transaction will be signed on the Hardware Device + +### Share PSBT + - Show the PSBT without sending it to the Hardware Wallet ## Screensots - screenshot 1: ![image](https://user-images.githubusercontent.com/2951406/177181611-eeeac70c-c245-4b45-b80b-8bbb511f6d1d.png) - screenshot 2: -![image](https://user-images.githubusercontent.com/2951406/177331468-f9b43626-548a-4608-b0d0-44007f402404.png) +![image](https://user-images.githubusercontent.com/2951406/183087898-b91f5243-8ed9-4a14-9e57-7bb4f1fd43ef.png) - screenshot 3: ![image](https://user-images.githubusercontent.com/2951406/177333755-4a9118fb-3eaf-43d6-bc7e-c3d8c80bc61e.png) diff --git a/lnbits/extensions/watchonly/crud.py b/lnbits/extensions/watchonly/crud.py index 0d28eb70..21fea6f0 100644 --- a/lnbits/extensions/watchonly/crud.py +++ b/lnbits/extensions/watchonly/crud.py @@ -4,8 +4,8 @@ from typing import List, Optional from lnbits.helpers import urlsafe_short_hash from . import db -from .helpers import derive_address, parse_key -from .models import Address, Config, Mempool, WalletAccount +from .helpers import derive_address +from .models import Address, Config, WalletAccount ##########################WALLETS#################### @@ -22,9 +22,10 @@ async def create_watch_wallet(w: WalletAccount) -> WalletAccount: title, type, address_no, - balance + balance, + network ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( wallet_id, @@ -35,6 +36,7 @@ async def create_watch_wallet(w: WalletAccount) -> WalletAccount: w.type, w.address_no, w.balance, + w.network, ), ) @@ -48,9 +50,10 @@ async def get_watch_wallet(wallet_id: str) -> Optional[WalletAccount]: return WalletAccount.from_row(row) if row else None -async def get_watch_wallets(user: str) -> List[WalletAccount]: +async def get_watch_wallets(user: str, network: str) -> List[WalletAccount]: rows = await db.fetchall( - """SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,) + """SELECT * FROM watchonly.wallets WHERE "user" = ? AND network = ?""", + (user, network), ) return [WalletAccount(**row) for row in rows] diff --git a/lnbits/extensions/watchonly/migrations.py b/lnbits/extensions/watchonly/migrations.py index 8e371a2a..0c06b738 100644 --- a/lnbits/extensions/watchonly/migrations.py +++ b/lnbits/extensions/watchonly/migrations.py @@ -77,7 +77,19 @@ async def m004_create_config_table(db): );""" ) - ### TODO: fix statspay dependcy first - # await db.execute( - # "DROP TABLE watchonly.wallets;" - # ) + +async def m005_add_network_column_to_wallets(db): + """ + Add network' column to the 'wallets' table + """ + + await db.execute( + "ALTER TABLE watchonly.wallets ADD COLUMN network TEXT DEFAULT 'Mainnet';" + ) + + +async def m006_drop_mempool_table(db): + """ + Mempool data is now part of `config` + """ + await db.execute("DROP TABLE watchonly.mempool;") diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py index bc10e421..4bf0dfca 100644 --- a/lnbits/extensions/watchonly/models.py +++ b/lnbits/extensions/watchonly/models.py @@ -1,5 +1,5 @@ from sqlite3 import Row -from typing import List +from typing import List, Optional from fastapi.param_functions import Query from pydantic import BaseModel @@ -8,6 +8,7 @@ from pydantic import BaseModel class CreateWallet(BaseModel): masterpub: str = Query("") title: str = Query("") + network: str = "Mainnet" class WalletAccount(BaseModel): @@ -19,22 +20,13 @@ class WalletAccount(BaseModel): address_no: int balance: int type: str = "" + network: str = "Mainnet" @classmethod def from_row(cls, row: Row) -> "WalletAccount": return cls(**dict(row)) -### TODO: fix statspay dependcy and remove -class Mempool(BaseModel): - user: str - endpoint: str - - @classmethod - def from_row(cls, row: Row) -> "Mempool": - return cls(**dict(row)) - - class Address(BaseModel): id: str address: str @@ -57,7 +49,7 @@ class TransactionInput(BaseModel): address: str branch_index: int address_index: int - masterpub_fingerprint: str + wallet: str tx_hex: str @@ -66,10 +58,11 @@ class TransactionOutput(BaseModel): address: str branch_index: int = None address_index: int = None - masterpub_fingerprint: str = None + wallet: str = None class MasterPublicKey(BaseModel): + id: str public_key: str fingerprint: str @@ -82,8 +75,23 @@ class CreatePsbt(BaseModel): tx_size: int +class ExtractPsbt(BaseModel): + psbtBase64 = "" # // todo snake case + inputs: List[TransactionInput] + + +class SignedTransaction(BaseModel): + tx_hex: Optional[str] + tx_json: Optional[str] + + +class BroadcastTransaction(BaseModel): + tx_hex: str + + class Config(BaseModel): mempool_endpoint = "https://mempool.space" receive_gap_limit = 20 change_gap_limit = 5 sats_denominated = True + network = "Mainnet" diff --git a/lnbits/extensions/watchonly/static/components/address-list/address-list.html b/lnbits/extensions/watchonly/static/components/address-list/address-list.html new file mode 100644 index 00000000..f2121063 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/address-list/address-list.html @@ -0,0 +1,204 @@ +
+
+
+ +
+
+ +
+
+ + + +
+
+ + + +
diff --git a/lnbits/extensions/watchonly/static/components/address-list/address-list.js b/lnbits/extensions/watchonly/static/components/address-list/address-list.js new file mode 100644 index 00000000..ebb8c87f --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/address-list/address-list.js @@ -0,0 +1,121 @@ +async function addressList(path) { + const template = await loadTemplateAsync(path) + Vue.component('address-list', { + name: 'address-list', + template, + + props: [ + 'addresses', + 'accounts', + 'mempool-endpoint', + 'inkey', + 'sats-denominated' + ], + data: function () { + return { + show: false, + history: [], + selectedWallet: null, + note: '', + filterOptions: [ + 'Show Change Addresses', + 'Show Gap Addresses', + 'Only With Amount' + ], + filterValues: [], + + addressesTable: { + columns: [ + { + name: 'expand', + align: 'left', + label: '' + }, + { + name: 'address', + align: 'left', + label: 'Address', + field: 'address', + sortable: true + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount', + sortable: true + }, + { + name: 'note', + align: 'left', + label: 'Note', + field: 'note', + sortable: true + }, + { + name: 'wallet', + align: 'left', + label: 'Account', + field: 'wallet', + sortable: true + } + ], + pagination: { + rowsPerPage: 0, + sortBy: 'amount', + descending: true + }, + filter: '' + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + getWalletName: function (walletId) { + const wallet = (this.accounts || []).find(wl => wl.id === walletId) + return wallet ? wallet.title : 'unknown' + }, + getFilteredAddresses: function () { + const selectedWalletId = this.selectedWallet?.id + const filter = this.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.accounts || []).reduce((r, w) => { + r[`_${w.id}`] = w.address_no + return r + }, {}) + + const fAddresses = this.addresses.filter( + a => + (includeChangeAddrs || !a.isChange) && + (includeGapAddrs || + a.isChange || + a.addressIndex <= walletsLimit[`_${a.wallet}`]) && + !(excludeNoAmount && a.amount === 0) && + (!selectedWalletId || a.wallet === selectedWalletId) + ) + return fAddresses + }, + + scanAddress: async function (addressData) { + this.$emit('scan:address', addressData) + }, + showAddressDetails: function (addressData) { + this.$emit('show-address-details', addressData) + }, + searchInTab: function (tab, value) { + this.$emit('search:tab', {tab, value}) + }, + updateNoteForAddress: async function (addressData, note) { + this.$emit('update:note', {addressId: addressData.id, note}) + } + }, + + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.html b/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.html new file mode 100644 index 00000000..c65ad1c4 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.html @@ -0,0 +1,61 @@ +
+
+
Fee Rate:
+
+ +
+
+ +
+
+
+
+
+ + Warning! The fee is too low. The transaction might take a long time to + confirm. + + + Warning! The fee is too high. You might be overpaying for this + transaction. + +
+
+ +
+
Fee:
+
{{feeValue}} sats
+
+ Refresh Fee Rates +
+
+
diff --git a/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.js b/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.js new file mode 100644 index 00000000..7a920a9a --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.js @@ -0,0 +1,64 @@ +async function feeRate(path) { + const template = await loadTemplateAsync(path) + Vue.component('fee-rate', { + name: 'fee-rate', + template, + + props: ['rate', 'fee-value', 'sats-denominated', 'mempool-endpoint'], + + computed: { + feeRate: { + get: function () { + return this['rate'] + }, + set: function (value) { + this.$emit('update:rate', +value) + } + } + }, + + data: function () { + return { + recommededFees: { + fastestFee: 1, + halfHourFee: 1, + hourFee: 1, + economyFee: 1, + minimumFee: 1 + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + + refreshRecommendedFees: async function () { + const fn = async () => { + const { + bitcoin: {fees: feesAPI} + } = mempoolJS({ + hostname: this.mempoolEndpoint + }) + return feesAPI.getFeesRecommended() + } + this.recommededFees = await retryWithDelay(fn) + }, + getFeeRateLabel: function (feeRate) { + const fees = this.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)` + } + }, + + created: async function () { + await this.refreshRecommendedFees() + this.feeRate = this.recommededFees.halfHourFee + } + }) +} diff --git a/lnbits/extensions/watchonly/static/components/history/history.html b/lnbits/extensions/watchonly/static/components/history/history.html new file mode 100644 index 00000000..ac805dfa --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/history/history.html @@ -0,0 +1,144 @@ +
+
+
+
+ + + +
+
+ + + + + Export to CSV + + + + +
+
+ + + +
diff --git a/lnbits/extensions/watchonly/static/components/history/history.js b/lnbits/extensions/watchonly/static/components/history/history.js new file mode 100644 index 00000000..574a1ef6 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/history/history.js @@ -0,0 +1,94 @@ +async function history(path) { + const template = await loadTemplateAsync(path) + Vue.component('history', { + name: 'history', + template, + + props: ['history', 'mempool-endpoint', 'sats-denominated', 'filter'], + data: function () { + return { + historyTable: { + columns: [ + { + name: 'expand', + align: 'left', + label: '' + }, + { + name: 'status', + align: 'left', + label: 'Status' + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount', + sortable: true + }, + { + name: 'address', + align: 'left', + label: 'Address', + field: 'address', + sortable: true + }, + { + name: 'date', + align: 'left', + label: 'Date', + field: 'date', + sortable: true + } + ], + exportColums: [ + { + label: 'Action', + field: 'action' + }, + { + label: 'Date&Time', + field: 'date' + }, + { + label: 'Amount', + field: 'amount' + }, + { + label: 'Fee', + field: 'fee' + }, + { + label: 'Transaction Id', + field: 'txId' + } + ], + pagination: { + rowsPerPage: 0 + } + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + getFilteredAddressesHistory: function () { + return this.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' + ) + } + }, + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.html b/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.html new file mode 100644 index 00000000..83af1248 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.html @@ -0,0 +1,5 @@ +
+
+
{{ title }}
+ XXX +
diff --git a/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.js b/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.js new file mode 100644 index 00000000..3d22c3a0 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.js @@ -0,0 +1,16 @@ +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/components/payment/payment.html b/lnbits/extensions/watchonly/static/components/payment/payment.html new file mode 100644 index 00000000..cde65ca2 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/payment/payment.html @@ -0,0 +1,312 @@ +
+ + + + + + + + + +
+
+ +
+ +
+
+ Fee Rate: + + {{feeRate}} sats/vbyte + Fee: + {{satBtc(feeValue)}} +
+
+
+ +
+
+ + +
+
+
+
+ + + +
+
+ +
+ +
+
+ Balance: + {{satBtc(balance)}} + Selected: + + {{satBtc(selectedAmount)}} + +
+
+
+ +
+
+ + +
+
+
+
+ + + +
+
+ +
+ +
+ + Below dust limit. Will be used as fee. + +
+
+
+ Change: + + {{satBtc(0)}} + + + {{satBtc(changeAmount)}} + +
+
+
+ +
+
+ +
+
Change Account:
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ + + + + Serial Port + + Sign using a Serial Port device + + + + + Share PSBT + Share the PSBT as text or Animated QR Code + + + + +
+ +
+ + + The payed amount is higher than the selected amount! + +
+
+
+ + + + +
+ Close +
+
+
+ + + +
+
+ Transaction Details +
+
+ +
+
+
+
Version
+
{{signedTx.version}}
+
+
+
Locktime
+
{{signedTx.locktime}}
+
+
+
Fee
+
+ {{satBtc(signedTx.fee)}} +
+
+ + Outputs + +
+
+ {{satBtc(out.amount)}} +
+ +
+ {{out.address}} +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+ Send + Close +
+
+
+
diff --git a/lnbits/extensions/watchonly/static/components/payment/payment.js b/lnbits/extensions/watchonly/static/components/payment/payment.js new file mode 100644 index 00000000..1459083c --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/payment/payment.js @@ -0,0 +1,336 @@ +async function payment(path) { + const t = await loadTemplateAsync(path) + Vue.component('payment', { + name: 'payment', + template: t, + + props: [ + 'accounts', + 'addresses', + 'utxos', + 'mempool-endpoint', + 'sats-denominated', + 'serial-signer-ref', + 'adminkey' + ], + watch: { + immediate: true, + accounts() { + this.updateChangeAddress() + }, + addresses() { + this.updateChangeAddress() + } + }, + + data: function () { + return { + DUST_LIMIT: 546, + tx: null, + psbtBase64: null, + psbtBase64Signed: null, + signedTx: null, + signedTxHex: null, + sentTxId: null, + signedTxId: null, + paymentTab: 'destination', + sendToList: [{address: '', amount: undefined}], + changeWallet: null, + changeAddress: {}, + showCustomFee: false, + showCoinSelect: false, + showChecking: false, + showChange: false, + showPsbt: false, + showFinalTx: false, + feeRate: 1 + } + }, + + computed: { + txSize: function () { + const tx = this.createTx() + return Math.round(txSize(tx)) + }, + txSizeNoChange: function () { + const tx = this.createTx(true) + return Math.round(txSize(tx)) + }, + feeValue: function () { + return this.feeRate * this.txSize + }, + selectedAmount: function () { + return this.utxos + .filter(utxo => utxo.selected) + .reduce((t, a) => t + (a.amount || 0), 0) + }, + changeAmount: function () { + return ( + this.selectedAmount - + this.totalPayedAmount - + this.feeRate * this.txSize + ) + }, + balance: function () { + return this.utxos.reduce((t, a) => t + (a.amount || 0), 0) + }, + totalPayedAmount: function () { + return this.sendToList.reduce((t, a) => t + (a.amount || 0), 0) + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + checkAndSend: async function () { + this.showChecking = true + try { + if (!this.serialSignerRef.isConnected()) { + const portOpen = await this.serialSignerRef.openSerialPort() + if (!portOpen) return + } + if (!this.serialSignerRef.isAuthenticated()) { + await this.serialSignerRef.hwwShowPasswordDialog() + const authenticated = await this.serialSignerRef.isAuthenticating() + if (!authenticated) return + } + + await this.createPsbt() + + if (this.psbtBase64) { + const txData = { + outputs: this.tx.outputs, + feeRate: this.tx.fee_rate, + feeValue: this.feeValue + } + await this.serialSignerRef.hwwSendPsbt(this.psbtBase64, txData) + await this.serialSignerRef.isSendingPsbt() + } + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Cannot check and sign PSBT!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.showChecking = false + this.psbtBase64 = null + } + }, + showPsbtDialog: async function () { + try { + const valid = await this.$refs.paymentFormRef.validate() + if (!valid) return + + const data = await this.createPsbt() + if (data) { + this.showPsbt = true + } + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to create PSBT!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + createPsbt: async function () { + try { + console.log('### this.createPsbt') + this.tx = this.createTx() + for (const input of this.tx.inputs) { + input.tx_hex = await this.fetchTxHex(input.tx_id) + } + + const changeOutput = this.tx.outputs.find(o => o.branch_index === 1) + if (changeOutput) changeOutput.amount = this.changeAmount + + const {data} = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/psbt', + this.adminkey, + this.tx + ) + + this.psbtBase64 = data + return data + } catch (err) { + LNbits.utils.notifyApiError(err) + } + }, + createTx: function (excludeChange = false) { + const tx = { + fee_rate: this.feeRate, + masterpubs: this.accounts.map(w => ({ + id: w.id, + public_key: w.masterpub, + fingerprint: w.fingerprint + })) + } + tx.inputs = this.utxos + .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.sendToList.map(out => ({ + address: out.address, + amount: out.amount + })) + + if (!excludeChange) { + const change = this.createChangeOutput() + const diffAmount = this.selectedAmount - this.totalPayedAmount + if (diffAmount >= this.DUST_LIMIT) { + tx.outputs.push(change) + } + } + tx.tx_size = Math.round(txSize(tx)) + tx.inputs = _.shuffle(tx.inputs) + tx.outputs = _.shuffle(tx.outputs) + + return tx + }, + createChangeOutput: function () { + const change = this.changeAddress + const walletAcount = + this.accounts.find(w => w.id === change.wallet) || {} + + return { + address: change.address, + address_index: change.addressIndex, + branch_index: change.isChange ? 1 : 0, + wallet: walletAcount.id + } + }, + selectChangeAddress: function (account) { + if (!account) this.changeAddress = '' + this.changeAddress = + this.addresses.find( + a => a.wallet === account.id && a.isChange && !a.hasActivity + ) || {} + }, + updateChangeAddress: function () { + if (this.changeWallet) { + const changeAccount = (this.accounts || []).find( + w => w.id === this.changeWallet.id + ) + // change account deleted + if (!changeAccount) { + this.changeWallet = this.accounts[0] + } + } else { + this.changeWallet = this.accounts[0] + } + this.selectChangeAddress(this.changeWallet) + }, + updateSignedPsbt: async function (psbtBase64) { + try { + this.showChecking = true + this.psbtBase64Signed = psbtBase64 + + console.log('### payment updateSignedPsbt psbtBase64', psbtBase64) + + const data = await this.extractTxFromPsbt(psbtBase64) + this.showFinalTx = true + if (data) { + this.signedTx = JSON.parse(data.tx_json) + this.signedTxHex = data.tx_hex + } else { + this.signedTx = null + this.signedTxHex = null + } + } finally { + this.showChecking = false + } + }, + extractTxFromPsbt: async function (psbtBase64) { + console.log('### extractTxFromPsbt psbtBase64', psbtBase64) + try { + const {data} = await LNbits.api.request( + 'PUT', + '/watchonly/api/v1/psbt/extract', + this.adminkey, + { + psbtBase64, + inputs: this.tx.inputs + } + ) + console.log('### extractTxFromPsbt data', data) + return data + } catch (error) { + console.log('### error', error) + this.$q.notify({ + type: 'warning', + message: 'Cannot finalize PSBT!', + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + } + }, + broadcastTransaction: async function () { + try { + const {data} = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/tx', + this.adminkey, + {tx_hex: this.signedTxHex} + ) + this.sentTxId = data + + this.$q.notify({ + type: 'positive', + message: 'Transaction broadcasted!', + caption: `${data}`, + timeout: 10000 + }) + + // todo: event rescan with amount + // todo: display tx id + } catch (error) { + this.sentTxId = null + this.$q.notify({ + type: 'warning', + message: 'Failed to broadcast!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.showFinalTx = false + } + }, + fetchTxHex: async function (txId) { + const { + bitcoin: {transactions: transactionsAPI} + } = mempoolJS({ + hostname: this.mempoolEndpoint + }) + + 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 + } + }, + handleOutputsChange: function () { + this.$refs.utxoList.refreshUtxoSelection(this.totalPayedAmount) + }, + getTotalPaymentAmount: function () { + return this.sendToList.reduce((t, a) => t + (a.amount || 0), 0) + } + }, + + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/send-to/send-to.html b/lnbits/extensions/watchonly/static/components/send-to/send-to.html new file mode 100644 index 00000000..c16ebf95 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/send-to/send-to.html @@ -0,0 +1,77 @@ +
+
+ + + +
+
+ Add +
+
+
+ Payed Amount: + + {{satBtc(getTotalPaymentAmount())}} + +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/static/components/send-to/send-to.js b/lnbits/extensions/watchonly/static/components/send-to/send-to.js new file mode 100644 index 00000000..2b93cea7 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/send-to/send-to.js @@ -0,0 +1,81 @@ +async function sendTo(path) { + const template = await loadTemplateAsync(path) + Vue.component('send-to', { + name: 'send-to', + template, + + props: [ + 'data', + 'tx-size', + 'selected-amount', + 'fee-rate', + 'sats-denominated' + ], + + computed: { + dataLocal: { + get: function () { + return this.data + }, + set: function (value) { + console.log('### computed update data', value) + this.$emit('update:data', value) + } + } + }, + + data: function () { + return { + DUST_LIMIT: 546, + paymentTable: { + columns: [ + { + name: 'data', + align: 'left' + } + ], + pagination: { + rowsPerPage: 10 + }, + filter: '' + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + addPaymentAddress: function () { + this.dataLocal.push({address: '', amount: undefined}) + this.handleOutputsChange() + }, + deletePaymentAddress: function (v) { + const index = this.dataLocal.indexOf(v) + if (index !== -1) { + this.dataLocal.splice(index, 1) + } + this.handleOutputsChange() + }, + + sendMaxToAddress: function (paymentAddress = {}) { + const feeValue = this.feeRate * this.txSize + const inputAmount = this.selectedAmount + const currentAmount = Math.max(0, paymentAddress.amount || 0) + const payedAmount = this.getTotalPaymentAmount() - currentAmount + paymentAddress.amount = Math.max( + 0, + inputAmount - payedAmount - feeValue + ) + }, + handleOutputsChange: function () { + this.$emit('update:outputs') + }, + getTotalPaymentAmount: function () { + return this.dataLocal.reduce((t, a) => t + (a.amount || 0), 0) + } + }, + + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.html b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.html new file mode 100644 index 00000000..392ace17 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.html @@ -0,0 +1,67 @@ +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
diff --git a/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.js b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.js new file mode 100644 index 00000000..8c155435 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.js @@ -0,0 +1,24 @@ +async function serialPortConfig(path) { + const t = await loadTemplateAsync(path) + Vue.component('serial-port-config', { + name: 'serial-port-config', + template: t, + data() { + return { + config: { + baudRate: 9600, + bufferSize: 255, + dataBits: 8, + flowControl: 'none', + parity: 'none', + stopBits: 1 + } + } + }, + methods: { + getConfig: function () { + return this.config + } + } + }) +} diff --git a/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.html b/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.html new file mode 100644 index 00000000..eed1c560 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.html @@ -0,0 +1,451 @@ +
+ + + + + Login + Enter password for Hardware Wallet. + + + + + + Logout + Clear password for HWW. + + + + + Config & Connect + Set the Serial Port communication parameters. + + + + + Disconnect + Disconnect from Serial Port. + + + + + + Restore + Restore wallet from existing word list. + + + + + Show Seed + Show seed on the Hardware Wallet display. + + + + + Wipe + Clean-up the wallet. New random seed. + + + + + Help + View available comands. + + + + + Console + Show the serial port communication messages + + + + + + + + + Enter Config + + + +
+ Connect + Cancel +
+
+
+
+ + + + + Enter password for Hardware Wallet (8 numbers/letters) + + +
+ Login + Cancel +
+
+
+
+ + + + +
+
+
+ Output {{hww.confirm.outputIndex}} + + change + +
+
+
+
+ Address: +
+
+ {{tx.outputs[hww.confirm.outputIndex].address}} +
+
+
+
+ Amount: +
+
+ {{satBtc(tx.outputs[hww.confirm.outputIndex].amount)}} +
+
+
+
+ Fee: +
+
+ {{satBtc(tx.feeValue)}} +
+
+
+
+ Fee Rate: +
+
+ {{tx.feeRate}} sats/vbyte +
+
+
+
+
+ + Check data on the display of the hardware device. + +
+
+
+
+ + + +
+
+ + +
+
+ Cancel +
+
+
+
+
+ + + + + + This action will remove all data from the Hardware Wallet. Please + create a back-up for the seed! + + Enter new password for Hardware Wallet (8 numbers/letters) + + + + + This action cannot be reversed! + + +
+ Wipe + Cancel +
+
+
+
+ + + + + +
+ Close +
+
+
+ + + + Check word at position {{hww.seedWordPosition}} on display + +
+
+ Prev +
+
+ Next +
+
+ Close +
+
+
+
+ + + + + + For test purposes only. Do not enter word list with real funds!!! + +


+ Enter new word list separated by space + + + +
+ Enter new password (8 numbers/letters) + + + + + +

+ + For test purposes only. Do not enter word list with real funds!!! + + + + ALL existing data on the Hardware Device will be lost. + + +
+ Restore + Cancel +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.js b/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.js new file mode 100644 index 00000000..b1255031 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.js @@ -0,0 +1,601 @@ +async function serialSigner(path) { + const t = await loadTemplateAsync(path) + Vue.component('serial-signer', { + name: 'serial-signer', + template: t, + + props: ['sats-denominated', 'network'], + data: function () { + return { + selectedPort: null, + writableStreamClosed: null, + writer: null, + readableStreamClosed: null, + reader: null, + receivedData: '', + config: {}, + + hww: { + password: null, + showPassword: false, + mnemonic: null, + showMnemonic: false, + authenticated: false, + showPasswordDialog: false, + showConfigDialog: false, + showWipeDialog: false, + showRestoreDialog: false, + showConfirmationDialog: false, + showSignedPsbt: false, + sendingPsbt: false, + signingPsbt: false, + loginResolve: null, + psbtSentResolve: null, + xpubResolve: null, + seedWordPosition: 1, + showSeedDialog: false, + confirm: { + outputIndex: 0, + showFee: false + } + }, + tx: null, // todo: move to hww + + showConsole: false + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + openSerialPortDialog: async function () { + await this.openSerialPort() + }, + openSerialPort: async function (config = {baudRate: 9600}) { + if (!this.checkSerialPortSupported()) return false + if (this.selectedPort) { + this.$q.notify({ + type: 'warning', + message: 'Already connected. Disconnect first!', + timeout: 10000 + }) + return true + } + + try { + navigator.serial.addEventListener('connect', event => { + console.log('### navigator.serial event: connected!', event) + }) + + navigator.serial.addEventListener('disconnect', () => { + console.log('### navigator.serial event: disconnected!', event) + this.hww.authenticated = false + this.$q.notify({ + type: 'warning', + message: 'Disconnected from Serial Port!', + timeout: 10000 + }) + }) + this.selectedPort = await navigator.serial.requestPort() + // Wait for the serial port to open. + await this.selectedPort.open(config) + this.startSerialPortReading() + + const textEncoder = new TextEncoderStream() + this.writableStreamClosed = textEncoder.readable.pipeTo( + this.selectedPort.writable + ) + + this.writer = textEncoder.writable.getWriter() + return true + } catch (error) { + this.selectedPort = null + this.$q.notify({ + type: 'warning', + message: 'Cannot open serial port!', + caption: `${error}`, + timeout: 10000 + }) + return false + } + }, + openSerialPortConfig: async function () { + this.hww.showConfigDialog = true + }, + closeSerialPort: async function () { + try { + if (this.writer) this.writer.close() + if (this.writableStreamClosed) await this.writableStreamClosed + if (this.reader) this.reader.cancel() + if (this.readableStreamClosed) + await this.readableStreamClosed.catch(() => { + /* Ignore the error */ + }) + if (this.selectedPort) await this.selectedPort.close() + this.selectedPort = null + this.$q.notify({ + type: 'positive', + message: 'Serial port disconnected!', + timeout: 5000 + }) + } catch (error) { + this.selectedPort = null + this.$q.notify({ + type: 'warning', + message: 'Cannot close serial port!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.hww.authenticated = false + } + }, + + isConnected: function () { + return !!this.selectedPort + }, + isAuthenticated: function () { + return this.hww.authenticated + }, + isAuthenticating: function () { + if (this.isAuthenticated()) return false + return new Promise(resolve => { + this.loginResolve = resolve + }) + }, + + isSendingPsbt: async function () { + if (!this.hww.sendingPsbt) return false + return new Promise(resolve => { + this.psbtSentResolve = resolve + }) + }, + + isFetchingXpub: async function () { + return new Promise(resolve => { + this.xpubResolve = resolve + }) + }, + + 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 + }, + startSerialPortReading: async function () { + const port = this.selectedPort + + while (port && port.readable) { + const textDecoder = new TextDecoderStream() + this.readableStreamClosed = port.readable.pipeTo(textDecoder.writable) + this.reader = textDecoder.readable.getReader() + const readStringUntil = readFromSerialPort(this.reader) + + try { + while (true) { + const {value, done} = await readStringUntil('\n') + if (value) { + this.handleSerialPortResponse(value) + this.updateSerialPortConsole(value) + } + if (done) return + } + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Serial port communication error!', + caption: `${error}`, + timeout: 10000 + }) + } + } + }, + handleSerialPortResponse: function (value) { + const command = value.split(' ')[0] + const commandData = value.substring(command.length).trim() + + switch (command) { + case COMMAND_SIGN_PSBT: + this.handleSignResponse(commandData) + break + case COMMAND_PASSWORD: + this.handleLoginResponse(commandData) + break + case COMMAND_PASSWORD_CLEAR: + this.handleLogoutResponse(commandData) + break + case COMMAND_SEND_PSBT: + this.handleSendPsbtResponse(commandData) + break + case COMMAND_WIPE: + this.handleWipeResponse(commandData) + break + case COMMAND_XPUB: + this.handleXpubResponse(commandData) + break + default: + console.log('### console', value) + } + }, + updateSerialPortConsole: function (value) { + this.receivedData += value + '\n' + const textArea = document.getElementById('serial-port-console') + if (textArea) textArea.scrollTop = textArea.scrollHeight + }, + hwwShowPasswordDialog: async function () { + try { + this.hww.showPasswordDialog = true + await this.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.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.writer.write(COMMAND_WIPE + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to connect to Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwConfirmNext: async function () { + this.hww.confirm.outputIndex += 1 + if (this.hww.confirm.outputIndex >= this.tx.outputs.length) { + this.hww.confirm.showFee = true + } + await this.writer.write(COMMAND_CONFIRM_NEXT + '\n') + }, + cancelOperation: async function () { + try { + await this.writer.write(COMMAND_CANCEL + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to send cancel!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwConfigAndConnect: async function () { + this.hww.showConfigDialog = false + const config = this.$refs.serialPortConfig.getConfig() + await this.openSerialPort(config) + return true + }, + hwwLogin: async function () { + try { + await this.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.loginResolve) { + this.loginResolve(this.hww.authenticated) + } + + 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.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 + }) + } + }, + hwwSendPsbt: async function (psbtBase64, tx) { + try { + this.tx = tx + this.hww.sendingPsbt = true + await this.writer.write( + COMMAND_SEND_PSBT + ' ' + this.network + ' ' + psbtBase64 + '\n' + ) + this.$q.notify({ + type: 'positive', + message: 'Data sent to serial port device!', + timeout: 5000 + }) + } catch (error) { + this.hww.sendingPsbt = false + this.$q.notify({ + type: 'warning', + message: 'Failed to send data to serial port!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleSendPsbtResponse: function (res = '') { + try { + const psbtOK = res.trim() === '1' + if (!psbtOK) { + this.$q.notify({ + type: 'warning', + message: 'Failed to send PSBT!', + caption: `${res}`, + timeout: 10000 + }) + return + } + this.hww.confirm.outputIndex = 0 + this.hww.showConfirmationDialog = true + this.hww.confirm = { + outputIndex: 0, + showFee: false + } + this.hww.sendingPsbt = false + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to send PSBT!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.psbtSentResolve() + } + }, + hwwSignPsbt: async function () { + try { + this.hww.showConfirmationDialog = false + this.hww.signingPsbt = true + await this.writer.write(COMMAND_SIGN_PSBT + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to sign PSBT!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleSignResponse: function (res = '') { + this.hww.signingPsbt = false + const [count, psbt] = res.trim().split(' ') + if (!psbt || !count || count.trim() === '0') { + this.$q.notify({ + type: 'warning', + message: 'No input signed!', + caption: 'Are you using the right seed?', + timeout: 10000 + }) + return + } + this.updateSignedPsbt(psbt) + this.$q.notify({ + type: 'positive', + message: 'Transaction Signed', + message: `Inputs signed: ${count}`, + timeout: 10000 + }) + }, + hwwHelp: async function () { + try { + await this.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.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.confirmedPassword = null + this.hww.showPassword = false + } + }, + handleWipeResponse: function (res = '') { + const wiped = res.trim() === '1' + 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 + }) + } + }, + hwwXpub: async function (path) { + try { + console.log( + '### hwwXpub', + COMMAND_XPUB + ' ' + this.network + ' ' + path + ) + await this.writer.write( + COMMAND_XPUB + ' ' + this.network + ' ' + path + '\n' + ) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to fetch XPub!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleXpubResponse: function (res = '') { + const args = res.trim().split(' ') + if (args.length < 3 || args[0].trim() !== '1') { + this.$q.notify({ + type: 'warning', + message: 'Failed to fetch XPub!', + caption: `${res}`, + timeout: 10000 + }) + this.xpubResolve({}) + return + } + const xpub = args[1].trim() + const fingerprint = args[2].trim() + this.xpubResolve({xpub, fingerprint}) + }, + hwwShowSeed: async function () { + try { + this.hww.showSeedDialog = true + this.hww.seedWordPosition = 1 + await this.writer.write( + COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n' + ) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to show seed!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + showNextSeedWord: async function () { + this.hww.seedWordPosition++ + await this.writer.write( + COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n' + ) + }, + showPrevSeedWord: async function () { + this.hww.seedWordPosition = Math.max(1, this.hww.seedWordPosition - 1) + console.log('### this.hww.seedWordPosition', this.hww.seedWordPosition) + await this.writer.write( + COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n' + ) + }, + handleShowSeedResponse: function (res = '') { + const args = res.trim().split(' ') + if (args.length < 2 || args[0].trim() !== '1') { + this.$q.notify({ + type: 'warning', + message: 'Failed to show seed!', + caption: `${res}`, + timeout: 10000 + }) + return + } + }, + hwwRestore: async function () { + try { + await this.writer.write( + COMMAND_RESTORE + ' ' + this.hww.mnemonic + '\n' + ) + await this.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.confirmedPassword = null + this.hww.showPassword = false + } + }, + + updateSignedPsbt: async function (value) { + this.$emit('signed:psbt', value) + } + }, + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.html b/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.html new file mode 100644 index 00000000..a55b99e9 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.html @@ -0,0 +1,135 @@ + + +
+
+ +
+
+ +
+
+
+
+ + + +
+
+ + + + +
diff --git a/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.js b/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.js new file mode 100644 index 00000000..6741ed94 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.js @@ -0,0 +1,148 @@ +async function utxoList(path) { + const template = await loadTemplateAsync(path) + Vue.component('utxo-list', { + name: 'utxo-list', + template, + + props: [ + 'utxos', + 'accounts', + 'selectable', + 'payed-amount', + 'sats-denominated', + 'mempool-endpoint', + 'filter' + ], + + data: function () { + return { + utxosTable: { + columns: [ + { + name: 'expand', + align: 'left', + label: '' + }, + { + name: 'selected', + align: 'left', + label: '', + selectable: true + }, + { + name: 'status', + align: 'center', + label: 'Status', + sortable: true + }, + { + name: 'address', + align: 'left', + label: 'Address', + field: 'address', + sortable: true + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount', + sortable: true + }, + { + name: 'date', + align: 'left', + label: 'Date', + field: 'date', + sortable: true + }, + { + name: 'wallet', + align: 'left', + label: 'Account', + field: 'wallet', + sortable: true + } + ], + pagination: { + rowsPerPage: 10 + } + }, + utxoSelectionModes: [ + 'Manual', + 'Random', + 'Select All', + 'Smaller Inputs First', + 'Larger Inputs First' + ], + utxoSelectionMode: 'Random', + utxoSelectAmount: 0 + } + }, + + computed: { + columns: function () { + return this.utxosTable.columns.filter(c => + c.selectable ? this.selectable : true + ) + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + getWalletName: function (walletId) { + const wallet = (this.accounts || []).find(wl => wl.id === walletId) + return wallet ? wallet.title : 'unknown' + }, + getTotalSelectedUtxoAmount: function () { + const total = (this.utxos || []) + .filter(u => u.selected) + .reduce((t, a) => t + (a.amount || 0), 0) + return total + }, + refreshUtxoSelection: function (totalPayedAmount) { + this.utxoSelectAmount = totalPayedAmount + this.applyUtxoSelectionMode() + }, + updateUtxoSelection: function () { + this.utxoSelectAmount = this.payedAmount + this.applyUtxoSelectionMode() + }, + applyUtxoSelectionMode: function () { + const mode = this.utxoSelectionMode + const isSelectAll = mode === 'Select All' + if (isSelectAll) { + this.utxos.forEach(u => (u.selected = true)) + return + } + + const isManual = mode === 'Manual' + if (isManual || !this.utxoSelectAmount) return + + this.utxos.forEach(u => (u.selected = false)) + + const isSmallerFirst = mode === 'Smaller Inputs First' + const isLargerFirst = mode === 'Larger Inputs First' + let selectedUtxos = this.utxos.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 < this.utxoSelectAmount + total += utxo.amount + return total + }, 0) + } + }, + + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.html b/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.html new file mode 100644 index 00000000..61a35362 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.html @@ -0,0 +1,80 @@ +
+ +
+
+ + +
+
+
+
{{satBtc(total)}}
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + +
+ Update + Cancel +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.js b/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.js new file mode 100644 index 00000000..447dc65c --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.js @@ -0,0 +1,67 @@ +async function walletConfig(path) { + const t = await loadTemplateAsync(path) + Vue.component('wallet-config', { + name: 'wallet-config', + template: t, + + props: ['total', 'config-data', 'adminkey'], + data: function () { + return { + networOptions: ['Mainnet', 'Testnet'], + internalConfig: {}, + show: false + } + }, + + computed: { + config: { + get() { + return this.internalConfig + }, + set(value) { + value.isLoaded = true + this.internalConfig = JSON.parse(JSON.stringify(value)) + this.$emit( + 'update:config-data', + JSON.parse(JSON.stringify(this.internalConfig)) + ) + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.config.sats_denominated) + }, + updateConfig: async function () { + try { + const {data} = await LNbits.api.request( + 'PUT', + '/watchonly/api/v1/config', + this.adminkey, + this.config + ) + this.show = false + this.config = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getConfig: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/config', + this.adminkey + ) + this.config = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } + }, + created: async function () { + await this.getConfig() + } + }) +} diff --git a/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.html b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.html new file mode 100644 index 00000000..ccd6f678 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.html @@ -0,0 +1,233 @@ +
+ + +
+
+ + + + + New Account + Enter account Xpub or Descriptor + + + + + From Hardware Device + + Get Xpub from a Hardware Device + + + + +
+ +
+
+ + + +
+
+ + + + +
+
+ + + + + + + + + + +
+ + + + Cancel +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.js b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.js new file mode 100644 index 00000000..adc82b3e --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.js @@ -0,0 +1,290 @@ +async function walletList(path) { + const template = await loadTemplateAsync(path) + Vue.component('wallet-list', { + name: 'wallet-list', + template, + + props: [ + 'adminkey', + 'inkey', + 'sats-denominated', + 'addresses', + 'network', + 'serial-signer-ref' + ], + data: function () { + return { + walletAccounts: [], + address: {}, + formDialog: { + show: false, + + addressType: { + label: 'Segwit (P2WPKH)', + id: 'wpkh', + pathMainnet: "m/84'/0'/0'", + pathTestnet: "m/84'/1'/0'" + }, + useSerialPort: false, + data: { + title: '', + masterpub: '' + } + }, + accountPath: '', + filter: '', + showCreating: false, + addressTypeOptions: [ + { + label: 'Legacy (P2PKH)', + id: 'pkh', + pathMainnet: "m/44'/0'/0'", + pathTestnet: "m/44'/1'/0'" + }, + { + label: 'Segwit (P2WPKH)', + id: 'wpkh', + pathMainnet: "m/84'/0'/0'", + pathTestnet: "m/84'/1'/0'" + }, + { + label: 'Wrapped Segwit (P2SH-P2WPKH)', + id: 'sh', + pathMainnet: "m/49'/0'/0'", + pathTestnet: "m/49'/1'/0'" + }, + { + label: 'Taproot (P2TR)', + id: 'tr', + pathMainnet: "m/86'/0'/0'", + pathTestnet: "m/86'/1'/0'" + } + ], + + walletsTable: { + columns: [ + { + name: 'new', + align: 'left', + label: '' + }, + { + name: 'title', + align: 'left', + label: 'Title', + field: 'title' + }, + { + name: 'amount', + align: 'left', + label: 'Amount' + }, + { + name: 'type', + align: 'left', + label: 'Type', + field: 'type' + }, + {name: 'id', align: 'left', label: 'ID', field: 'id'} + ], + pagination: { + rowsPerPage: 10 + }, + filter: '' + } + } + }, + watch: { + immediate: true, + async network(newNet, oldNet) { + if (newNet !== oldNet) { + await this.refreshWalletAccounts() + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + + addWalletAccount: async function () { + this.showCreating = true + const data = _.omit(this.formDialog.data, 'wallet') + data.network = this.network + await this.createWalletAccount(data) + this.showCreating = false + }, + createWalletAccount: async function (data) { + try { + if (this.formDialog.useSerialPort) { + const {xpub, fingerprint} = await this.fetchXpubFromHww() + if (!xpub) return + const path = this.accountPath.substring(2) + const outputType = this.formDialog.addressType.id + if (outputType === 'sh') { + data.masterpub = `${outputType}(wpkh([${fingerprint}/${path}]${xpub}/{0,1}/*))` + } else { + data.masterpub = `${outputType}([${fingerprint}/${path}]${xpub}/{0,1}/*)` + } + } + const response = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/wallet', + this.adminkey, + data + ) + this.walletAccounts.push(mapWalletAccount(response.data)) + this.formDialog.show = false + + await this.refreshWalletAccounts() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + fetchXpubFromHww: async function () { + const error = findAccountPathIssues(this.accountPath) + if (error) { + this.$q.notify({ + type: 'warning', + message: 'Invalid derivation path.', + caption: error, + timeout: 10000 + }) + return + } + await this.serialSignerRef.hwwXpub(this.accountPath) + return await this.serialSignerRef.isFetchingXpub() + }, + 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.adminkey + ) + this.walletAccounts = _.reject(this.walletAccounts, function ( + obj + ) { + return obj.id === walletAccountId + }) + await this.refreshWalletAccounts() + } catch (error) { + this.$q.notify({ + type: 'warning', + message: + 'Error while deleting wallet account. Please try again.', + timeout: 10000 + }) + } + }) + }, + + getWatchOnlyWallets: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + `/watchonly/api/v1/wallet?network=${this.network}`, + this.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 () { + this.walletAccounts = [] + const wallets = await this.getWatchOnlyWallets() + this.walletAccounts = wallets.map(w => mapWalletAccount(w)) + this.$emit('accounts-update', this.walletAccounts) + }, + getAmmountForWallet: function (walletId) { + const amount = this.addresses + .filter(a => a.wallet === walletId) + .reduce((t, a) => t + a.amount || 0, 0) + return this.satBtc(amount) + }, + closeFormDialog: function () { + this.formDialog.data = { + is_unique: false + } + }, + getAccountDescription: function (accountType) { + return getAccountDescription(accountType) + }, + openGetFreshAddressDialog: async function (walletId) { + const {data} = await LNbits.api.request( + 'GET', + `/watchonly/api/v1/address/${walletId}`, + this.inkey + ) + const addressData = mapAddressesData(data) + + addressData.note = `Shared on ${currentDateTime()}` + const lastAcctiveAddress = + this.addresses + .filter( + a => + a.wallet === addressData.wallet && !a.isChange && a.hasActivity + ) + .pop() || {} + addressData.gapLimitExceeded = + !addressData.isChange && + addressData.addressIndex > + lastAcctiveAddress.addressIndex + DEFAULT_RECEIVE_GAP_LIMIT + + const wallet = this.walletAccounts.find(w => w.id === walletId) || {} + wallet.address_no = addressData.addressIndex + this.$emit('new-receive-address', addressData) + }, + showAddAccountDialog: function () { + this.formDialog.show = true + this.formDialog.useSerialPort = false + }, + getXpubFromDevice: async function () { + try { + if (!this.serialSignerRef.isConnected()) { + const portOpen = await this.serialSignerRef.openSerialPort() + if (!portOpen) return + } + if (!this.serialSignerRef.isAuthenticated()) { + await this.serialSignerRef.hwwShowPasswordDialog() + const authenticated = await this.serialSignerRef.isAuthenticating() + if (!authenticated) return + } + this.formDialog.show = true + this.formDialog.useSerialPort = true + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Cannot fetch Xpub!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleAddressTypeChanged: function (value = {}) { + const addressType = + this.addressTypeOptions.find(t => t.id === value.id) || {} + this.accountPath = addressType[`path${this.network}`] + } + }, + created: async function () { + if (this.inkey) { + await this.refreshWalletAccounts() + this.handleAddressTypeChanged(this.addressTypeOptions[1]) + } + } + }) +} diff --git a/lnbits/extensions/watchonly/static/js/index.js b/lnbits/extensions/watchonly/static/js/index.js index f44d30cd..68204aca 100644 --- a/lnbits/extensions/watchonly/static/js/index.js +++ b/lnbits/extensions/watchonly/static/js/index.js @@ -1,743 +1,399 @@ -Vue.component(VueQrcode.name, VueQrcode) +const watchOnly = async () => { + Vue.component(VueQrcode.name, VueQrcode) -Vue.filter('reverse', function (value) { - // slice to make a copy of array, then reverse the copy - return value.slice().reverse() -}) + await walletConfig('static/components/wallet-config/wallet-config.html') + await walletList('static/components/wallet-list/wallet-list.html') + await addressList('static/components/address-list/address-list.html') + await history('static/components/history/history.html') + await utxoList('static/components/utxo-list/utxo-list.html') + await feeRate('static/components/fee-rate/fee-rate.html') + await sendTo('static/components/send-to/send-to.html') + await payment('static/components/payment/payment.html') + await serialSigner('static/components/serial-signer/serial-signer.html') + await serialPortConfig( + 'static/components/serial-port-config/serial-port-config.html' + ) -new Vue({ - el: '#vue', - mixins: [windowMixin], - data: function () { - return { - DUST_LIMIT: 546, - filter: '', + Vue.filter('reverse', function (value) { + // slice to make a copy of array, then reverse the copy + return value.slice().reverse() + }) - 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 + new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + scan: { + scanning: false, + scanCount: 0, + scanIndex: 0 }, - DEFAULT_RECEIVE_GAP_LIMIT: 20, - show: false - }, - formDialog: { - show: false, - data: {} - }, + currentAddress: null, - qrCodeDialog: { - show: false, - data: null - }, - ...tables, - ...tableData - } - }, + tab: 'addresses', - 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) + config: {sats_denominated: true}, + + qrCodeDialog: { + show: false, + data: null + }, + ...tables, + ...tableData, + + walletAccounts: [], + addresses: [], + history: [], + historyFilter: '', + + showAddress: false, + addressNote: '', + showPayment: false, + fetchedUtxos: false, + utxosFilter: '', + network: null } }, - 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) + computed: { + mempoolHostname: function () { + if (!this.config.isLoaded) return + let hostname = new URL(this.config.mempool_endpoint).hostname + if (this.config.network === 'Testnet') { + hostname += '/testnet' } - } catch (error) { - LNbits.utils.notifyApiError(error) + return hostname } }, - 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 + + methods: { + 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 ) - 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 + addressWallet && + addressWallet.address_no < addressData.addressIndex ) { - this.payment.changeWallet = this.walletAccounts[0] - this.selectChangeAddress(this.payment.changeWallet) + addressWallet.address_no = addressData.addressIndex } - await this.scanAddressWithAmount() - } catch (error) { + } + + // todo: account deleted + 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 ({addressId, note}) { + try { + const wallet = this.g.user.wallets[0] + await LNbits.api.request( + 'PUT', + `/watchonly/api/v1/address/${addressId}`, + wallet.adminkey, + {note} + ) + const updatedAddress = + this.addresses.find(a => a.id === addressId) || {} + updatedAddress.note = note + } catch (err) { + LNbits.utils.notifyApiError(err) + } + }, + + //################### 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 + }, + + markSameTxAddressHistory: function () { + this.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 + }) + }, + + //################### PAYMENT ################### + + initPaymentData: async function () { + if (!this.payment.show) return + await this.refreshAddresses() + }, + + goToPaymentView: async function () { + this.showPayment = true + await this.initPaymentData() + }, + + //################### PSBT ################### + + updateSignedPsbt: async function (psbtBase64) { + this.$refs.paymentRef.updateSignedPsbt(psbtBase64) + }, + + //################### SERIAL PORT ################### + + //################### HARDWARE WALLET ################### + + //################### UTXOs ################### + scanAllAddresses: async function () { + await this.refreshAddresses() + this.history = [] + let addresses = this.addresses + 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.slice() + await this.refreshAddresses() + const newAddresses = this.addresses.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: 'warning', - message: 'Error while deleting wallet account. Please try again.', + type: 'positive', + message: 'Funds found! Scanning for more...', 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) || {} - - 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 - }, - 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) - } - - const {data} = await LNbits.api.request( - 'POST', - '/watchonly/api/v1/psbt', - wallet.adminkey, - tx - ) - - this.payment.psbtBase64 = data - } catch (err) { - LNbits.utils.notifyApiError(err) - } - }, - 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) - }, - - //################### 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++) { + }, + scanAddressWithAmount: async function () { + this.utxos.data = [] + this.utxos.total = 0 + this.history = [] + const addresses = this.addresses.filter(a => a.hasActivity) 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) { + }, + scanAddress: async function (addressData) { + this.updateUtxosForAddresses([addressData]) + this.$q.notify({ + type: 'positive', + message: 'Address Rescanned', + timeout: 10000 + }) + }, + refreshAddresses: async function () { + if (!this.walletAccounts) return + this.addresses = [] + for (const {id, type} of this.walletAccounts) { + const newAddresses = await this.getAddressesForWallet(id) + const uniqueAddresses = newAddresses.filter( + newAddr => !this.addresses.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 + DEFAULT_RECEIVE_GAP_LIMIT + }) + this.addresses.push(...uniqueAddresses) + } + this.$emit('update:addresses', this.addresses) + }, + 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 (error) { this.$q.notify({ - type: 'positive', - message: 'Funds found! Scanning for more...', + type: 'warning', + message: `Failed to fetch addresses for wallet with id ${walletId}.`, timeout: 10000 }) + LNbits.utils.notifyApiError(error) } - } - }, - 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} + return [] + }, + 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 - ) + try { + for (addrData of addresses) { + const addressHistory = await this.getAddressTxsDelayed(addrData) + // remove old entries + this.history = this.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() + // add new entries + this.history.push(...addressHistory) + this.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) + 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++ } - - 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 } - } 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) || {} + }, + 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), + 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 + this.updateAmountForAddress(addressData, addressTotal) + }, - 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 - return total - }, 0) - }, - - //################### MEMPOOL API ################### - getAddressTxsDelayed: async function (addrData) { - const { - bitcoin: {addresses: addressesAPI} - } = mempoolJS({ - hostname: new URL(this.config.data.mempool_endpoint).hostname - }) - - const fn = async () => - addressesAPI.getAddressTxs({ - address: addrData.address + //################### MEMPOOL API ################### + getAddressTxsDelayed: async function (addrData) { + const accounts = this.walletAccounts + const { + bitcoin: {addresses: addressesAPI} + } = mempoolJS({ + hostname: this.mempoolHostname }) - const addressTxs = await retryWithDelay(fn) - return this.addressHistoryFromTxs(addrData, addressTxs) - }, + const fn = async () => { + if (!accounts.find(w => w.id === addrData.wallet)) return [] + return addressesAPI.getAddressTxs({ + address: addrData.address + }) + } + const addressTxs = await retryWithDelay(fn) + return this.addressHistoryFromTxs(addrData, addressTxs) + }, - refreshRecommendedFees: async function () { - const { - bitcoin: {fees: feesAPI} - } = mempoolJS({ - hostname: new URL(this.config.data.mempool_endpoint).hostname - }) - - 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.getAddressTxsUtxo({ - address + getAddressTxsUtxoDelayed: async function (address) { + const endpoint = this.mempoolHostname + const { + bitcoin: {addresses: addressesAPI} + } = mempoolJS({ + hostname: endpoint }) - return retryWithDelay(fn) - }, - fetchTxHex: async function (txId) { - const { - bitcoin: {transactions: transactionsAPI} - } = mempoolJS({ - hostname: new URL(this.config.data.mempool_endpoint).hostname - }) - 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 () => { + if (endpoint !== this.mempoolHostname) return [] + return addressesAPI.getAddressTxsUtxo({ + address + }) + } + return retryWithDelay(fn) + }, + + //################### OTHER ################### + + openQrCodeDialog: function (addressData) { + this.currentAddress = addressData + this.addressNote = addressData.note || '' + this.showAddress = true + }, + searchInTab: function ({tab, value}) { + this.tab = tab + this[`${tab}Filter`] = value + }, + + updateAccounts: async function (accounts) { + this.walletAccounts = accounts + await this.refreshAddresses() + await this.scanAddressWithAmount() + }, + showAddressDetails: function (addressData) { + this.openQrCodeDialog(addressData) + }, + initUtxos: function (addresses) { + if (!this.fetchedUtxos && addresses.length) { + this.fetchedUtxos = true + this.addresses = addresses + this.scanAddressWithAmount() + } } }, - - //################### OTHER ################### - closeFormDialog: function () { - this.formDialog.data = { - is_unique: false + created: async function () { + if (this.g.user.wallets.length) { + 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/map.js b/lnbits/extensions/watchonly/static/js/map.js index d1bc8038..ecc0b316 100644 --- a/lnbits/extensions/watchonly/static/js/map.js +++ b/lnbits/extensions/watchonly/static/js/map.js @@ -43,7 +43,7 @@ const mapUtxoToPsbtInput = utxo => ({ address: utxo.address, branch_index: utxo.isChange ? 1 : 0, address_index: utxo.addressIndex, - masterpub_fingerprint: utxo.masterpubFingerprint, + wallet: utxo.wallet, accountType: utxo.accountType, txHex: '' }) @@ -66,15 +66,15 @@ const mapAddressDataToUtxo = (wallet, addressData, utxo) => ({ selected: false }) -const mapWalletAccount = function (obj) { - obj._data = _.clone(obj) - obj.date = obj.time - ? Quasar.utils.date.formatDate( - new Date(obj.time * 1000), - 'YYYY-MM-DD HH:mm' - ) - : '' - obj.label = obj.title // for drop-downs - obj.expanded = false - return obj +const mapWalletAccount = function (o) { + return Object.assign({}, o, { + date: o.time + ? Quasar.utils.date.formatDate( + new Date(o.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + : '', + label: o.title, + expanded: false + }) } diff --git a/lnbits/extensions/watchonly/static/js/tables.js b/lnbits/extensions/watchonly/static/js/tables.js index fdd558bd..f437bcd5 100644 --- a/lnbits/extensions/watchonly/static/js/tables.js +++ b/lnbits/extensions/watchonly/static/js/tables.js @@ -1,99 +1,4 @@ const tables = { - walletsTable: { - columns: [ - { - name: 'new', - align: 'left', - label: '' - }, - { - name: 'title', - align: 'left', - label: 'Title', - field: 'title' - }, - { - name: 'amount', - align: 'left', - label: 'Amount' - }, - { - name: 'type', - align: 'left', - label: 'Type', - field: 'type' - }, - {name: 'id', align: 'left', label: 'ID', field: 'id'} - ], - pagination: { - rowsPerPage: 10 - }, - filter: '' - }, - utxosTable: { - columns: [ - { - name: 'expand', - align: 'left', - label: '' - }, - { - name: 'selected', - align: 'left', - label: '' - }, - { - name: 'status', - align: 'center', - label: 'Status', - sortable: true - }, - { - name: 'address', - align: 'left', - label: 'Address', - field: 'address', - sortable: true - }, - { - name: 'amount', - align: 'left', - label: 'Amount', - field: 'amount', - sortable: true - }, - { - name: 'date', - align: 'left', - label: 'Date', - field: 'date', - sortable: true - }, - { - name: 'wallet', - align: 'left', - label: 'Account', - field: 'wallet', - sortable: true - } - ], - pagination: { - rowsPerPage: 10 - }, - filter: '' - }, - paymentTable: { - columns: [ - { - name: 'data', - align: 'left' - } - ], - pagination: { - rowsPerPage: 10 - }, - filter: '' - }, summaryTable: { columns: [ { @@ -117,157 +22,36 @@ const tables = { label: 'Change' } ] - }, - addressesTable: { - columns: [ - { - name: 'expand', - align: 'left', - label: '' - }, - { - name: 'address', - align: 'left', - label: 'Address', - field: 'address', - sortable: true - }, - { - name: 'amount', - align: 'left', - label: 'Amount', - field: 'amount', - sortable: true - }, - { - name: 'note', - align: 'left', - label: 'Note', - field: 'note', - sortable: true - }, - { - name: 'wallet', - align: 'left', - label: 'Account', - field: 'wallet', - sortable: true - } - ], - pagination: { - rowsPerPage: 0, - sortBy: 'amount', - descending: true - }, - filter: '' - }, - historyTable: { - columns: [ - { - name: 'expand', - align: 'left', - label: '' - }, - { - name: 'status', - align: 'left', - label: 'Status' - }, - { - name: 'amount', - align: 'left', - label: 'Amount', - field: 'amount', - sortable: true - }, - { - name: 'address', - align: 'left', - label: 'Address', - field: 'address', - sortable: true - }, - { - name: 'date', - align: 'left', - label: 'Date', - field: 'date', - sortable: true - } - ], - exportColums: [ - { - label: 'Action', - field: 'action' - }, - { - label: 'Date&Time', - field: 'date' - }, - { - label: 'Amount', - field: 'amount' - }, - { - label: 'Fee', - field: 'fee' - }, - { - label: 'Transaction Id', - field: 'txId' - } - ], - pagination: { - rowsPerPage: 0 - }, - filter: '' } } const tableData = { - walletAccounts: [], - addresses: { - show: false, - data: [], - history: [], - selectedWallet: null, - note: '', - filterOptions: [ - 'Show Change Addresses', - 'Show Gap Addresses', - 'Only With Amount' - ], - filterValues: [] - }, utxos: { data: [], total: 0 }, payment: { - data: [{address: '', amount: undefined}], - changeWallet: null, - changeAddress: {}, - changeAmount: 0, - - feeRate: 1, - recommededFees: { - fastestFee: 1, - halfHourFee: 1, - hourFee: 1, - economyFee: 1, - minimumFee: 1 - }, fee: 0, txSize: 0, + tx: null, psbtBase64: '', - utxoSelectionModes: [ - 'Manual', - 'Random', - 'Select All', - 'Smaller Inputs First', - 'Larger Inputs First' + psbtBase64Signed: '', + signedTx: null, + signedTxHex: null, + sentTxId: null, + + signModes: [ + { + label: 'Serial Port Device', + value: 'serial-port' + }, + { + label: 'Animated QR', + value: 'animated-qr', + disable: true + } ], - utxoSelectionMode: 'Manual', + signMode: '', show: false, showAdvanced: false }, diff --git a/lnbits/extensions/watchonly/static/js/utils.js b/lnbits/extensions/watchonly/static/js/utils.js index 26bebac6..6065d74c 100644 --- a/lnbits/extensions/watchonly/static/js/utils.js +++ b/lnbits/extensions/watchonly/static/js/utils.js @@ -1,3 +1,18 @@ +const PSBT_BASE64_PREFIX = 'cHNidP8' +const COMMAND_PASSWORD = '/password' +const COMMAND_PASSWORD_CLEAR = '/password-clear' +const COMMAND_SEND_PSBT = '/psbt' +const COMMAND_SIGN_PSBT = '/sign' +const COMMAND_HELP = '/help' +const COMMAND_WIPE = '/wipe' +const COMMAND_SEED = '/seed' +const COMMAND_RESTORE = '/restore' +const COMMAND_CONFIRM_NEXT = '/confirm-next' +const COMMAND_CANCEL = '/cancel' +const COMMAND_XPUB = '/xpub' + +const DEFAULT_RECEIVE_GAP_LIMIT = 20 + const blockTimeToDate = blockTime => blockTime ? moment(blockTime * 1000).format('LLL') : '' @@ -97,3 +112,72 @@ const ACCOUNT_TYPES = { } const getAccountDescription = type => ACCOUNT_TYPES[type] || 'nonstandard' + +const readFromSerialPort = reader => { + let partialChunk + let fulliness = [] + + const readStringUntil = async (separator = '\n') => { + if (fulliness.length) return fulliness.shift().trim() + const chunks = [] + if (partialChunk) { + // leftovers from previous read + chunks.push(partialChunk) + partialChunk = undefined + } + while (true) { + const {value, done} = await reader.read() + if (value) { + const values = value.split(separator) + // found one or more separators + if (values.length > 1) { + chunks.push(values.shift()) // first element + partialChunk = values.pop() // last element + fulliness = values // full lines + return {value: chunks.join('').trim(), done: false} + } + chunks.push(value) + } + if (done) return {value: chunks.join('').trim(), done: true} + } + } + return readStringUntil +} + +function satOrBtc(val, showUnit = true, showSats = false) { + const value = showSats + ? LNbits.utils.formatSat(val) + : val == 0 + ? 0.0 + : (val / 100000000).toFixed(8) + if (!showUnit) return value + return showSats ? value + ' sat' : value + ' BTC' +} + +function loadTemplateAsync(path) { + const result = new Promise(resolve => { + const xhttp = new XMLHttpRequest() + + xhttp.onreadystatechange = function () { + if (this.readyState == 4) { + if (this.status == 200) resolve(this.responseText) + + if (this.status == 404) resolve(`
Page not found: ${path}
`) + } + } + + xhttp.open('GET', path, true) + xhttp.send() + }) + + return result +} + +function findAccountPathIssues(path = '') { + const p = path.split('/') + if (p[0] !== 'm') return "Path must start with 'm/'" + for (let i = 1; i < p.length; i++) { + if (p[i].endsWith('')) p[i] = p[i].substring(0, p[i].length - 1) + if (isNaN(p[i])) return `${p[i]} is not a valid value` + } +} diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html index ff596699..982f9041 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/index.html +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -2,198 +2,70 @@ %} {% block page %}
- - {% raw %} -
-
-
-
{{satBtc(utxos.total)}}
-
-
-
- - -
-
-
+ + + - - -
-
- Add Wallet Account - -
- -
-
- - - -
-
- - - - -
-
+ + + {% raw %}
-
+
Scan Blockchain +
+
-
+
Make PaymentNew Payment + Back
@@ -208,828 +80,64 @@
- - + + - + -
-
- -
-
- -
-
- - - -
-
- - - +
-
-
-
- - - -
-
- - - - - Export to CSV - - - - -
-
- - - +
-
-
- -
-
-
- - - - -
-
- - - - -
-
-
- - -
-
- -
-
- -
-
-
-
- - - -
-
- - - - - -
-
-
- -
-
- -
-
Change Account:
-
- -
-
- -
-
-
-
Fee Rate:
-
- -
-
- -
-
-
-
-
- - Warning! The fee is too low. The transaction might take - a long time to confirm. - - - Warning! The fee is too high. You might be overpaying - for this transaction. - -
-
- -
-
Fee:
-
{{computeFee()}} sats
-
- Refresh Fee Rates -
-
-
- -
-
- - - -
-
-
-
- Create PSBT -
-
- - The payed amount is higher than the selected amount! - -
-
-
-
- -
-
-
-
-
+
+
+ + +
{% endraw %} @@ -1047,100 +155,9 @@
- - - - - - - - -
- Add Watch-Only Account - Cancel -
-
-
-
- - - - - - - - - - - -
- Update - Cancel -
-
-
-
- - - - {% raw %} - + {% raw %} + +
Address Details

@@ -1160,7 +177,7 @@ size="ms" icon="launch" type="a" - :href="config.mempool_endpoint + '/address/' + currentAddress.address" + :href="mempoolHostname + '/address/' + currentAddress.address" target="_blank" >

@@ -1168,7 +185,7 @@ @@ -1185,7 +202,7 @@ outline v-close-popup color="grey" - @click="updateNoteForAddress(currentAddress, addresses.note)" + @click="updateNoteForAddress({addressId: currentAddress.id, note: addressNote})" class="q-ml-sm" >Save Note @@ -1194,15 +211,31 @@
+ {% endraw %}
{% endblock %} {% block scripts %} {{ window_vars(user) }} - + + + + + + + + + + + + {% endblock %} diff --git a/lnbits/extensions/watchonly/views_api.py b/lnbits/extensions/watchonly/views_api.py index ae656540..1a4b93ed 100644 --- a/lnbits/extensions/watchonly/views_api.py +++ b/lnbits/extensions/watchonly/views_api.py @@ -1,7 +1,8 @@ +import json from http import HTTPStatus -from embit import script -from embit.descriptor import Descriptor, Key +import httpx +from embit import finalizer, script from embit.ec import PublicKey from embit.psbt import PSBT, DerivationPath from embit.transaction import Transaction, TransactionInput, TransactionOutput @@ -28,18 +29,31 @@ from .crud import ( update_watch_wallet, ) from .helpers import parse_key -from .models import Config, CreatePsbt, CreateWallet, WalletAccount +from .models import ( + BroadcastTransaction, + Config, + CreatePsbt, + CreateWallet, + ExtractPsbt, + SignedTransaction, + WalletAccount, +) ###################WALLETS############################# @watchonly_ext.get("/api/v1/wallet") -async def api_wallets_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_wallets_retrieve( + network: str = Query("Mainnet"), wallet: WalletTypeInfo = Depends(get_key_type) +): try: - return [wallet.dict() for wallet in await get_watch_wallets(wallet.wallet.user)] + return [ + wallet.dict() + for wallet in await get_watch_wallets(wallet.wallet.user, network) + ] except: - return "" + return [] @watchonly_ext.get("/api/v1/wallet/{wallet_id}") @@ -61,7 +75,13 @@ async def api_wallet_create_or_update( data: CreateWallet, w: WalletTypeInfo = Depends(require_admin_key) ): try: - (descriptor, _) = parse_key(data.masterpub) + (descriptor, network) = parse_key(data.masterpub) + if data.network != network["name"]: + raise ValueError( + "Account network error. This account is for '{}'".format( + network["name"] + ) + ) new_wallet = WalletAccount( id="none", @@ -72,11 +92,19 @@ async def api_wallet_create_or_update( title=data.title, address_no=-1, # so fresh address on empty wallet can get address with index 0 balance=0, + network=network["name"], ) - wallets = await get_watch_wallets(w.wallet.user) + wallets = await get_watch_wallets(w.wallet.user, network["name"]) existing_wallet = next( - (ew for ew in wallets if ew.fingerprint == new_wallet.fingerprint), None + ( + ew + for ew in wallets + if ew.fingerprint == new_wallet.fingerprint + and ew.network == new_wallet.network + and ew.masterpub == new_wallet.masterpub + ), + None, ) if existing_wallet: raise ValueError( @@ -215,12 +243,13 @@ async def api_psbt_create( descriptors = {} for _, masterpub in enumerate(data.masterpubs): - descriptors[masterpub.fingerprint] = parse_key(masterpub.public_key) + descriptors[masterpub.id] = parse_key(masterpub.public_key) inputs_extra = [] - bip32_derivations = {} + for i, inp in enumerate(data.inputs): - descriptor = descriptors[inp.masterpub_fingerprint][0] + bip32_derivations = {} + descriptor = descriptors[inp.wallet][0] d = descriptor.derive(inp.address_index, inp.branch_index) for k in d.keys: bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath( @@ -239,12 +268,13 @@ async def api_psbt_create( for i, inp in enumerate(inputs_extra): psbt.inputs[i].bip32_derivations = inp["bip32_derivations"] psbt.inputs[i].non_witness_utxo = inp.get("non_witness_utxo", None) + print("### ", inp.get("non_witness_utxo", None)) outputs_extra = [] bip32_derivations = {} for i, out in enumerate(data.outputs): if out.branch_index == 1: - descriptor = descriptors[out.masterpub_fingerprint][0] + descriptor = descriptors[out.wallet][0] d = descriptor.derive(out.address_index, out.branch_index) for k in d.keys: bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath( @@ -261,6 +291,66 @@ async def api_psbt_create( raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) +@watchonly_ext.put("/api/v1/psbt/extract") +async def api_psbt_extract_tx( + data: ExtractPsbt, w: WalletTypeInfo = Depends(require_admin_key) +): + res = SignedTransaction() + try: + psbt = PSBT.from_base64(data.psbtBase64) + for i, inp in enumerate(data.inputs): + psbt.inputs[i].non_witness_utxo = Transaction.from_string(inp.tx_hex) + + final_psbt = finalizer.finalize_psbt(psbt) + if not final_psbt: + raise ValueError("PSBT cannot be finalized!") + res.tx_hex = final_psbt.to_string() + + transaction = Transaction.from_string(res.tx_hex) + tx = { + "locktime": transaction.locktime, + "version": transaction.version, + "outputs": [], + "fee": psbt.fee(), + } + + for out in transaction.vout: + tx["outputs"].append( + {"amount": out.value, "address": out.script_pubkey.address()} + ) + res.tx_json = json.dumps(tx) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + return res.dict() + + +@watchonly_ext.post("/api/v1/tx") +async def api_tx_broadcast( + data: BroadcastTransaction, w: WalletTypeInfo = Depends(require_admin_key) +): + try: + config = await get_config(w.wallet.user) + if not config: + raise ValueError( + "Cannot broadcast transaction. Mempool endpoint not defined!" + ) + + endpoint = ( + config.mempool_endpoint + if config.network == "Mainnet" + else config.mempool_endpoint + "/testnet" + ) + async with httpx.AsyncClient() as client: + r = await client.post(endpoint + "/api/tx", data=data.tx_hex) + tx_id = r.text + print("### broadcast tx_id: ", tx_id) + return tx_id + # return "0f0f0f0f0f0f0f0f0f0f0f00f0f0f0f0f0f0f0f0f0f00f0f0f0f0f0f0.mock.transaction.id" + except Exception as e: + print("### broadcast error: ", str(e)) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + + #############################CONFIG##########################