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:

- screenshot 2:
-
+
- screenshot 3:

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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{satBtc(props.row.amount)}}
+
+
+
+ {{props.row.note}}
+
+
+ {{getWalletName(props.row.wallet)}}
+
+
+
+
+
+
+
+
+ QR Code
+
+
+
+ Rescan
+
+
+ History
+
+
+ View Coins
+
+
+
+
+
Note:
+
+
+
+
+ Update
+
+
+
+
+
+
+
+ {{props.row.error}}
+
+
+
+
+
+ Gap limit of 20 addresses exceeded. Other wallets might not
+ detect funds at this address.
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{props.row.confirmed ? 'Sent' : 'Sending...'}}
+
+
+ {{props.row.confirmed ? 'Received' : 'Receiving...'}}
+
+
+
+ {{satBtc(props.row.totalAmount || props.row.amount)}}
+
+
+
+ {{props.row.address}}
+
+ ...
+
+
+ {{ props.row.date }}
+
+
+
+
+
+
UTXOs
+
{{satBtc(props.row.amount)}}
+
{{props.row.address}}
+
+
+
+
{{satBtc(s.amount)}}
+
{{s.address}}
+
+
+
Fee
+
{{satBtc(props.row.fee)}}
+
+
+
Block Height
+
{{props.row.height}}
+
+
+
+
+
+
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 @@
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Max
+
+
+
+
+
+
+
+ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Confirmed
+
+
+ Pending
+
+
+
+
+
+
+
+
+ {{satBtc(props.row.amount)}}
+
+
+ {{ props.row.date }}
+
+ {{getWalletName(props.row.wallet)}}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+
+
+ New Receive Address
+
+
+
+
+ {{props.row.title}}
+
+
+ {{getAmmountForWallet(props.row.id)}}
+
+
+ {{props.row.type}}
+
+
+ {{props.row.id}}
+
+
+
+
+
+
+
+ New Receive Address
+
+
+ {{getAccountDescription(props.row.type)}}
+
+
+
+
+
+
Master Pubkey:
+
+
+
+
+
+
+
Last Address Index:
+
+ {{props.row.address_no}}
+ none
+
+
+
+
+
Fingerprint:
+
{{props.row.fingerprint}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ col.label }}
-
-
-
-
-
-
-
-
-
-
-
- New Receive Address
-
-
-
-
- {{props.row.title}}
-
-
- {{getAmmountForWallet(props.row.id)}}
-
-
- {{props.row.type}}
-
-
- {{props.row.id}}
-
-
-
-
-
-
-
- New Receive Address
-
-
- {{getAccountDescription(props.row.type)}}
-
-
-
-
-
-
Master Pubkey:
-
-
-
-
-
-
-
Last Address Index:
-
- {{props.row.address_no}}
- none
-
-
-
-
-
Fingerprint:
-
{{props.row.fingerprint}}
-
-
-
-
-
-
-
-
-
+
+
+ {% raw %}
-
+
Scan Blockchain
+
+
-
+
Make PaymentNew Payment
+ Back
@@ -208,828 +80,64 @@
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{satBtc(props.row.amount)}}
-
-
-
- {{props.row.note}}
-
-
- {{getWalletName(props.row.wallet)}}
-
-
-
-
-
-
-
- QR Code
-
-
- Rescan
-
-
- History
-
-
- View Coins
-
-
-
-
-
Note:
-
-
-
-
- Update
-
-
-
-
-
-
- {{props.row.error}}
-
-
-
-
-
- Gap limit of 20 addresses exceeded. Other wallets
- might not detect funds at this address.
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Export to CSV
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{props.row.confirmed ? 'Sent' : 'Sending...'}}
-
-
- {{props.row.confirmed ? 'Received' : 'Receiving...'}}
-
-
-
-
- {{satBtc(props.row.totalAmount || props.row.amount)}}
-
-
-
-
- {{props.row.address}}
-
- ...
-
-
- {{ props.row.date }}
-
-
-
-
-
-
UTXOs
-
- {{satBtc(props.row.amount)}}
-
-
{{props.row.address}}
-
-
-
-
{{satBtc(s.amount)}}
-
{{s.address}}
-
-
-
Fee
-
{{satBtc(props.row.fee)}}
-
-
-
Block Height
-
{{props.row.height}}
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Max
-
-
-
-
-
-
-
-
-
- Add Send Address
-
-
-
-
- Payed Amount:
-
-
- {{satBtc(getTotalPaymentAmount())}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Confirmed
-
-
- Pending
-
-
-
-
-
-
-
-
- {{satBtc(props.row.amount)}}
-
-
-
- {{ props.row.date }}
-
-
- {{getWalletName(props.row.wallet)}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Slected Amount:
-
-
- {{satBtc(getTotalSelectedUtxoAmount())}}
-
-
-
-
-
-
-
-
-
-
-
-
-
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
-
-
-
-
-
-
-
-
-
-
-
- {{satBtc(getTotalSelectedUtxoAmount())}}
-
-
-
-
- {{satBtc(getTotalPaymentAmount())}}
-
-
-
-
- {{satBtc(computeFee())}}
-
-
-
-
- {{payment.changeAmount ? payment.changeAmount:
- 'no change'}}
-
-
- Below dust limit. Will be used as feee.
-
-
-
-
-
-
-
-
-
- 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##########################