diff --git a/lnbits/extensions/watchonly/crud.py b/lnbits/extensions/watchonly/crud.py index 0ce3ead9..8ad79b5d 100644 --- a/lnbits/extensions/watchonly/crud.py +++ b/lnbits/extensions/watchonly/crud.py @@ -1,116 +1,66 @@ +import json from typing import List, Optional -from embit.descriptor import Descriptor, Key # type: ignore -from embit.descriptor.arguments import AllowedDerivation # type: ignore -from embit.networks import NETWORKS # type: ignore from lnbits.helpers import urlsafe_short_hash from . import db -from .models import Addresses, Mempool, Wallets +from .models import Address, Config, Mempool, WalletAccount +from .helpers import parse_key, derive_address + ##########################WALLETS#################### -def detect_network(k): - version = k.key.version - for network_name in NETWORKS: - net = NETWORKS[network_name] - # not found in this network - if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]: - return net - - -def parse_key(masterpub: str): - """Parses masterpub or descriptor and returns a tuple: (Descriptor, network) - To create addresses use descriptor.derive(num).address(network=network) - """ - network = None - # probably a single key - if "(" not in masterpub: - k = Key.from_string(masterpub) - if not k.is_extended: - raise ValueError("The key is not a master public key") - if k.is_private: - raise ValueError("Private keys are not allowed") - # check depth - if k.key.depth != 3: - raise ValueError( - "Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors." - ) - # if allowed derivation is not provided use default /{0,1}/* - if k.allowed_derivation is None: - k.allowed_derivation = AllowedDerivation.default() - # get version bytes - version = k.key.version - for network_name in NETWORKS: - net = NETWORKS[network_name] - # not found in this network - if version in [net["xpub"], net["ypub"], net["zpub"]]: - network = net - if version == net["xpub"]: - desc = Descriptor.from_string("pkh(%s)" % str(k)) - elif version == net["ypub"]: - desc = Descriptor.from_string("sh(wpkh(%s))" % str(k)) - elif version == net["zpub"]: - desc = Descriptor.from_string("wpkh(%s)" % str(k)) - break - # we didn't find correct version - if network is None: - raise ValueError("Unknown master public key version") - else: - desc = Descriptor.from_string(masterpub) - if not desc.is_wildcard: - raise ValueError("Descriptor should have wildcards") - for k in desc.keys: - if k.is_extended: - net = detect_network(k) - if net is None: - raise ValueError(f"Unknown version: {k}") - if network is not None and network != net: - raise ValueError("Keys from different networks") - network = net - return desc, network - - -async def create_watch_wallet(user: str, masterpub: str, title: str) -> Wallets: +async def create_watch_wallet(user: str, masterpub: str, title: str) -> WalletAccount: # check the masterpub is fine, it will raise an exception if not - parse_key(masterpub) + (descriptor, _) = parse_key(masterpub) + + type = descriptor.scriptpubkey_type() + fingerprint = descriptor.keys[0].fingerprint.hex() wallet_id = urlsafe_short_hash() + + wallets = await get_watch_wallets(user) + w = next((w for w in wallets if w.fingerprint == fingerprint), None) + if w: + raise ValueError("Account '{}' has the same master pulic key".format(w.title)) + await db.execute( """ INSERT INTO watchonly.wallets ( id, "user", masterpub, + fingerprint, title, + type, address_no, balance ) - VALUES (?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, # address_no is -1 so fresh address on empty wallet can get address with index 0 - (wallet_id, user, masterpub, title, -1, 0), + (wallet_id, user, masterpub, fingerprint, title, type, -1, 0), ) return await get_watch_wallet(wallet_id) -async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]: +async def get_watch_wallet(wallet_id: str) -> Optional[WalletAccount]: row = await db.fetchone( "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) ) - return Wallets.from_row(row) if row else None + return WalletAccount.from_row(row) if row else None -async def get_watch_wallets(user: str) -> List[Wallets]: +async def get_watch_wallets(user: str) -> List[WalletAccount]: rows = await db.fetchall( """SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,) ) - return [Wallets(**row) for row in rows] + return [WalletAccount(**row) for row in rows] -async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: +async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[WalletAccount]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) await db.execute( @@ -119,7 +69,7 @@ async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: row = await db.fetchone( "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) ) - return Wallets.from_row(row) if row else None + return WalletAccount.from_row(row) if row else None async def delete_watch_wallet(wallet_id: str) -> None: @@ -128,56 +78,173 @@ async def delete_watch_wallet(wallet_id: str) -> None: ########################ADDRESSES####################### -async def get_derive_address(wallet_id: str, num: int): - wallet = await get_watch_wallet(wallet_id) - key = wallet.masterpub - desc, network = parse_key(key) - return desc.derive(num).address(network=network) - - -async def get_fresh_address(wallet_id: str) -> Optional[Addresses]: +async def get_fresh_address(wallet_id: str) -> Optional[Address]: wallet = await get_watch_wallet(wallet_id) if not wallet: return None - address = await get_derive_address(wallet_id, wallet.address_no + 1) + wallet_addresses = await get_addresses(wallet_id) + receive_addresses = list( + filter( + lambda addr: addr.branch_index == 0 and addr.amount != 0, wallet_addresses + ) + ) + last_receive_index = ( + receive_addresses.pop().address_index if receive_addresses else -1 + ) + address_index = ( + last_receive_index + if last_receive_index > wallet.address_no + else wallet.address_no + ) - await update_watch_wallet(wallet_id=wallet_id, address_no=wallet.address_no + 1) - masterpub_id = urlsafe_short_hash() - await db.execute( - """ + address = await get_address_at_index(wallet_id, 0, address_index + 1) + + if not address: + addresses = await create_fresh_addresses( + wallet_id, address_index + 1, address_index + 2 + ) + address = addresses.pop() + + await update_watch_wallet(wallet_id, **{"address_no": address_index + 1}) + + return address + + +async def create_fresh_addresses( + wallet_id: str, + start_address_index: int, + end_address_index: int, + change_address=False, +) -> List[Address]: + if start_address_index > end_address_index: + return None + + wallet = await get_watch_wallet(wallet_id) + if not wallet: + return None + + branch_index = 1 if change_address else 0 + + for address_index in range(start_address_index, end_address_index): + address = await derive_address(wallet.masterpub, address_index, branch_index) + + await db.execute( + """ INSERT INTO watchonly.addresses ( id, address, wallet, - amount + amount, + branch_index, + address_index ) - VALUES (?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?) """, - (masterpub_id, address, wallet_id, 0), + (urlsafe_short_hash(), address, wallet_id, 0, branch_index, address_index), + ) + + # return fresh addresses + rows = await db.fetchall( + """ + SELECT * FROM watchonly.addresses + WHERE wallet = ? AND branch_index = ? AND address_index >= ? AND address_index < ? + ORDER BY branch_index, address_index + """, + (wallet_id, branch_index, start_address_index, end_address_index), ) - return await get_address(address) + return [Address(**row) for row in rows] -async def get_address(address: str) -> Optional[Addresses]: +async def get_address(address: str) -> Optional[Address]: row = await db.fetchone( "SELECT * FROM watchonly.addresses WHERE address = ?", (address,) ) - return Addresses.from_row(row) if row else None + return Address.from_row(row) if row else None -async def get_addresses(wallet_id: str) -> List[Addresses]: - rows = await db.fetchall( - "SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,) +async def get_address_at_index( + wallet_id: str, branch_index: int, address_index: int +) -> Optional[Address]: + row = await db.fetchone( + """ + SELECT * FROM watchonly.addresses + WHERE wallet = ? AND branch_index = ? AND address_index = ? + """, + ( + wallet_id, + branch_index, + address_index, + ), ) - return [Addresses(**row) for row in rows] + return Address.from_row(row) if row else None + + +async def get_addresses(wallet_id: str) -> List[Address]: + rows = await db.fetchall( + """ + SELECT * FROM watchonly.addresses WHERE wallet = ? + ORDER BY branch_index, address_index + """, + (wallet_id,), + ) + + return [Address(**row) for row in rows] + + +async def update_address(id: str, **kwargs) -> Optional[Address]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"""UPDATE watchonly.addresses SET {q} WHERE id = ? """, + (*kwargs.values(), id), + ) + row = await db.fetchone("SELECT * FROM watchonly.addresses WHERE id = ?", (id)) + return Address.from_row(row) if row else None + + +async def delete_addresses_for_wallet(wallet_id: str) -> None: + await db.execute("DELETE FROM watchonly.addresses WHERE wallet = ?", (wallet_id,)) + + +######################CONFIG####################### +async def create_config(user: str) -> Config: + config = Config() + await db.execute( + """ + INSERT INTO watchonly.config ("user", json_data) + VALUES (?, ?) + """, + (user, json.dumps(config.dict())), + ) + row = await db.fetchone( + """SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,) + ) + return json.loads(row[0], object_hook=lambda d: Config(**d)) + + +async def update_config(config: Config, user: str) -> Optional[Config]: + await db.execute( + f"""UPDATE watchonly.config SET json_data = ? WHERE "user" = ?""", + (json.dumps(config.dict()), user), + ) + row = await db.fetchone( + """SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,) + ) + return json.loads(row[0], object_hook=lambda d: Config(**d)) + + +async def get_config(user: str) -> Optional[Config]: + row = await db.fetchone( + """SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,) + ) + return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None ######################MEMPOOL####################### - - +### TODO: fix statspay dependcy and remove async def create_mempool(user: str) -> Optional[Mempool]: await db.execute( """ @@ -192,6 +259,7 @@ async def create_mempool(user: str) -> Optional[Mempool]: return Mempool.from_row(row) if row else None +### TODO: fix statspay dependcy and remove async def update_mempool(user: str, **kwargs) -> Optional[Mempool]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) @@ -205,6 +273,7 @@ async def update_mempool(user: str, **kwargs) -> Optional[Mempool]: return Mempool.from_row(row) if row else None +### TODO: fix statspay dependcy and remove async def get_mempool(user: str) -> Mempool: row = await db.fetchone( """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) diff --git a/lnbits/extensions/watchonly/helpers.py b/lnbits/extensions/watchonly/helpers.py new file mode 100644 index 00000000..74125dde --- /dev/null +++ b/lnbits/extensions/watchonly/helpers.py @@ -0,0 +1,69 @@ +from embit.descriptor import Descriptor, Key # type: ignore +from embit.descriptor.arguments import AllowedDerivation # type: ignore +from embit.networks import NETWORKS # type: ignore + + +def detect_network(k): + version = k.key.version + for network_name in NETWORKS: + net = NETWORKS[network_name] + # not found in this network + if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]: + return net + + +def parse_key(masterpub: str) -> Descriptor: + """Parses masterpub or descriptor and returns a tuple: (Descriptor, network) + To create addresses use descriptor.derive(num).address(network=network) + """ + network = None + # probably a single key + if "(" not in masterpub: + k = Key.from_string(masterpub) + if not k.is_extended: + raise ValueError("The key is not a master public key") + if k.is_private: + raise ValueError("Private keys are not allowed") + # check depth + if k.key.depth != 3: + raise ValueError( + "Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors." + ) + # if allowed derivation is not provided use default /{0,1}/* + if k.allowed_derivation is None: + k.allowed_derivation = AllowedDerivation.default() + # get version bytes + version = k.key.version + for network_name in NETWORKS: + net = NETWORKS[network_name] + # not found in this network + if version in [net["xpub"], net["ypub"], net["zpub"]]: + network = net + if version == net["xpub"]: + desc = Descriptor.from_string("pkh(%s)" % str(k)) + elif version == net["ypub"]: + desc = Descriptor.from_string("sh(wpkh(%s))" % str(k)) + elif version == net["zpub"]: + desc = Descriptor.from_string("wpkh(%s)" % str(k)) + break + # we didn't find correct version + if network is None: + raise ValueError("Unknown master public key version") + else: + desc = Descriptor.from_string(masterpub) + if not desc.is_wildcard: + raise ValueError("Descriptor should have wildcards") + for k in desc.keys: + if k.is_extended: + net = detect_network(k) + if net is None: + raise ValueError(f"Unknown version: {k}") + if network is not None and network != net: + raise ValueError("Keys from different networks") + network = net + return desc, network + + +async def derive_address(masterpub: str, num: int, branch_index=0): + desc, network = parse_key(masterpub) + return desc.derive(num, branch_index).address(network=network) diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py index d0894097..1c7c2deb 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 fastapi.param_functions import Query from pydantic import BaseModel @@ -9,19 +9,22 @@ class CreateWallet(BaseModel): title: str = Query("") -class Wallets(BaseModel): +class WalletAccount(BaseModel): id: str user: str masterpub: str + fingerprint: str title: str address_no: int balance: int + type: str = "" @classmethod - def from_row(cls, row: Row) -> "Wallets": + def from_row(cls, row: Row) -> "WalletAccount": return cls(**dict(row)) +### TODO: fix statspay dependcy and remove class Mempool(BaseModel): user: str endpoint: str @@ -31,12 +34,55 @@ class Mempool(BaseModel): return cls(**dict(row)) -class Addresses(BaseModel): +class Address(BaseModel): id: str address: str wallet: str - amount: int + amount: int = 0 + branch_index: int = 0 + address_index: int + note: str = None + has_activity: bool = False @classmethod - def from_row(cls, row: Row) -> "Addresses": + def from_row(cls, row: Row) -> "Address": return cls(**dict(row)) + + +class TransactionInput(BaseModel): + tx_id: str + vout: int + amount: int + address: str + branch_index: int + address_index: int + masterpub_fingerprint: str + tx_hex: str + + +class TransactionOutput(BaseModel): + amount: int + address: str + branch_index: int = None + address_index: int = None + masterpub_fingerprint: str = None + + +class MasterPublicKey(BaseModel): + public_key: str + fingerprint: str + + +class CreatePsbt(BaseModel): + masterpubs: List[MasterPublicKey] + inputs: List[TransactionInput] + outputs: List[TransactionOutput] + fee_rate: int + tx_size: int + + +class Config(BaseModel): + mempool_endpoint = "https://mempool.space" + receive_gap_limit = 20 + change_gap_limit = 5 + sats_denominated = True diff --git a/lnbits/extensions/watchonly/static/js/index.js b/lnbits/extensions/watchonly/static/js/index.js new file mode 100644 index 00000000..a9eae8c7 --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/index.js @@ -0,0 +1,726 @@ +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() +}) + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + DUST_LIMIT: 546, + filter: '', + + scan: { + scanning: false, + scanCount: 0, + scanIndex: 0 + }, + + currentAddress: null, + + tab: 'addresses', + + config: { + data: { + mempool_endpoint: 'https://mempool.space', + receive_gap_limit: 20, + change_gap_limit: 5 + }, + DEFAULT_RECEIVE_GAP_LIMIT: 20, + show: false + }, + + formDialog: { + show: false, + data: {} + }, + + qrCodeDialog: { + show: false, + data: null + }, + ...tables, + ...tableData + } + }, + + methods: { + //################### CONFIG ################### + getConfig: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/config', + this.g.user.wallets[0].adminkey + ) + this.config.data = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + 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() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + deleteWalletAccount: function (linkId) { + 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/' + linkId, + this.g.user.wallets[0].adminkey + ) + this.walletAccounts = _.reject(this.walletAccounts, function (obj) { + return obj.id === linkId + }) + await this.refreshWalletAccounts() + await this.refreshAddresses() + console.log('### 111') + await this.scanAddressWithAmount() + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Error while deleting wallet account. Please try again.', + timeout: 10000 + }) + } + }) + }, + getAddressesForWallet: async function (walletId) { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/addresses/' + walletId, + this.g.user.wallets[0].inkey + ) + return data.map(mapAddressesData) + } catch (err) { + this.$q.notify({ + type: 'warning', + message: `Failed to fetch addresses for wallet with id ${walletId}.`, + timeout: 10000 + }) + LNbits.utils.notifyApiError(err) + } + return [] + }, + getWatchOnlyWallets: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/wallet', + this.g.user.wallets[0].inkey + ) + return data + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to fetch wallets.', + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + } + return [] + }, + refreshWalletAccounts: async function () { + const wallets = await this.getWatchOnlyWallets() + this.walletAccounts = wallets.map(w => mapWalletAccount(w)) + }, + getAmmountForWallet: function (walletId) { + const amount = this.addresses.data + .filter(a => a.wallet === walletId) + .reduce((t, a) => t + a.amount || 0, 0) + return this.satBtc(amount) + }, + + //################### ADDRESSES ################### + + refreshAddresses: async function () { + const wallets = await this.getWatchOnlyWallets() + this.addresses.data = [] + for (const {id, type} of wallets) { + const newAddresses = await this.getAddressesForWallet(id) + const uniqueAddresses = newAddresses.filter( + newAddr => + !this.addresses.data.find(a => a.address === newAddr.address) + ) + + const lastAcctiveAddress = + uniqueAddresses + .filter(a => a.addressIndex === 0 && a.hasActivity) + .pop() || {} + + uniqueAddresses.forEach(a => { + a.expanded = false + a.accountType = type + a.gapLimitExceeded = + a.branchIndex === 0 && + 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.branchIndex === 0) { + 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.addressIndex === 0) && + (includeGapAddrs || + a.addressIndex === 1 || + a.addressIndex <= walletsLimit[`_${a.wallet}`]) && + !(excludeNoAmount && a.amount === 0) && + (!selectedWalletId || a.wallet === selectedWalletId) + ) + return addresses + }, + openGetFreshAddressDialog: async function (walletId) { + const {data: addressData} = await LNbits.api.request( + 'GET', + `/watchonly/api/v1/address/${walletId}`, + this.g.user.wallets[0].inkey + ) + + addressData.note = `Shared on ${currentDateTime()}` + const lastAcctiveAddress = + this.addresses.data + .filter( + a => + a.wallet === addressData.wallet && + a.addressIndex === 0 && + a.hasActivity + ) + .pop() || {} + addressData.gapLimitExceeded = + addressData.addressIndex === 0 && + 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.selectChangeAccount(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) + }, + selectChangeAccount: function (wallet) { + this.payment.changeAddress = + this.addresses.data.find( + a => a.wallet === wallet.id && a.addressIndex === 1 && !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++) { + await this.updateUtxosForAddresses(addresses) + const oldAddresses = this.addresses.data.slice() + await this.refreshAddresses() + const newAddresses = this.addresses.data.slice() + // check if gap addresses have been extended + addresses = newAddresses.filter( + newAddr => !oldAddresses.find(oldAddr => oldAddr.id === newAddr.id) + ) + if (addresses.length) { + this.$q.notify({ + type: 'positive', + message: 'Funds found! Scanning for more...', + timeout: 10000 + }) + } + } + }, + scanAddressWithAmount: async function () { + this.utxos.data = [] + this.utxos.total = 0 + this.addresses.history = [] + const addresses = this.addresses.data.filter(a => a.hasActivity) + await this.updateUtxosForAddresses(addresses) + }, + scanAddress: async function (addressData) { + this.updateUtxosForAddresses([addressData]) + this.$q.notify({ + type: 'positive', + message: 'Address Rescanned', + timeout: 10000 + }) + }, + updateUtxosForAddresses: async function (addresses = []) { + this.scan = {scanning: true, scanCount: addresses.length, scanIndex: 0} + + try { + for (addrData of addresses) { + const addressHistory = await this.getAddressTxsDelayed(addrData) + // remove old entries + this.addresses.history = this.addresses.history.filter( + h => h.address !== addrData.address + ) + + // add new entrie + this.addresses.history.push(...addressHistory) + this.addresses.history.sort((a, b) => + !a.height ? -1 : b.height - a.height + ) + this.markSameTxAddressHistory() + + if (addressHistory.length) { + // search only if it ever had any activity + const utxos = await this.getAddressTxsUtxoDelayed(addrData.address) + this.updateUtxosForAddress(addrData, utxos) + } + + this.scan.scanIndex++ + } + } catch (error) { + console.error(error) + this.$q.notify({ + type: 'warning', + message: 'Failed to scan addresses', + timeout: 10000 + }) + } finally { + this.scan.scanning = false + } + }, + updateUtxosForAddress: function (addressData, utxos = []) { + const wallet = + this.walletAccounts.find(w => w.id === addressData.wallet) || {} + + const newUtxos = utxos.map(utxo => + mapAddressDataToUtxo(wallet, addressData, utxo) + ) + // remove old utxos + this.utxos.data = this.utxos.data.filter( + u => u.address !== addressData.address + ) + // add new utxos + this.utxos.data.push(...newUtxos) + if (utxos.length) { + this.utxos.data.sort((a, b) => b.sort - a.sort) + this.utxos.total = this.utxos.data.reduce( + (total, y) => (total += y?.amount || 0), + 0 + ) + } + const addressTotal = utxos.reduce( + (total, y) => (total += y?.value || 0), + 0 + ) + this.updateAmountForAddress(addressData, addressTotal) + }, + getTotalSelectedUtxoAmount: function () { + const total = this.utxos.data + .filter(u => u.selected) + .reduce((t, a) => t + (a.amount || 0), 0) + return total + }, + applyUtxoSelectionMode: function () { + const payedAmount = this.getTotalPaymentAmount() + const mode = this.payment.utxoSelectionMode + this.utxos.data.forEach(u => (u.selected = false)) + const isManual = mode === 'Manual' + if (isManual || !payedAmount) return + + const isSelectAll = mode === 'Select All' + if (isSelectAll || payedAmount >= this.utxos.total) { + this.utxos.data.forEach(u => (u.selected = true)) + return + } + const isSmallerFirst = mode === 'Smaller Inputs First' + const isLargerFirst = mode === 'Larger Inputs First' + + let selectedUtxos = this.utxos.data.slice() + if (isSmallerFirst || isLargerFirst) { + const sortFn = isSmallerFirst + ? (a, b) => a.amount - b.amount + : (a, b) => b.amount - a.amount + selectedUtxos.sort(sortFn) + } else { + // default to random order + selectedUtxos = _.shuffle(selectedUtxos) + } + selectedUtxos.reduce((total, utxo) => { + utxo.selected = total < payedAmount + total += utxo.amount + return total + }, 0) + }, + + //################### MEMPOOL API ################### + getAddressTxsDelayed: async function (addrData) { + const { + bitcoin: {addresses: addressesAPI} + } = mempoolJS() + + const fn = async () => + addressesAPI.getAddressTxs({ + address: addrData.address + }) + const addressTxs = await retryWithDelay(fn) + return this.addressHistoryFromTxs(addrData, addressTxs) + }, + + refreshRecommendedFees: async function () { + const { + bitcoin: {fees: feesAPI} + } = mempoolJS() + + const fn = async () => feesAPI.getFeesRecommended() + this.payment.recommededFees = await retryWithDelay(fn) + }, + getAddressTxsUtxoDelayed: async function (address) { + const { + bitcoin: {addresses: addressesAPI} + } = mempoolJS() + + const fn = async () => + addressesAPI.getAddressTxsUtxo({ + address + }) + return retryWithDelay(fn) + }, + fetchTxHex: async function (txId) { + const { + bitcoin: {transactions: transactionsAPI} + } = mempoolJS() + + try { + const response = await transactionsAPI.getTxHex({txid: txId}) + return response + } catch (error) { + this.$q.notify({ + type: 'warning', + message: `Failed to fetch transaction details for tx id: '${txId}'`, + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + throw error + } + }, + + //################### OTHER ################### + closeFormDialog: function () { + this.formDialog.data = { + is_unique: false + } + }, + openQrCodeDialog: function (addressData) { + this.currentAddress = addressData + this.addresses.note = addressData.note || '' + this.addresses.show = true + }, + searchInTab: function (tab, value) { + this.tab = tab + this[`${tab}Table`].filter = value + }, + + satBtc(val, showUnit = true) { + const value = this.config.data.sats_denominated + ? LNbits.utils.formatSat(val) + : val == 0 + ? 0.0 + : (val / 100000000).toFixed(8) + if (!showUnit) return value + return this.config.data.sats_denominated ? value + ' sat' : value + ' BTC' + }, + getAccountDescription: function (accountType) { + return getAccountDescription(accountType) + } + }, + created: async function () { + if (this.g.user.wallets.length) { + await this.getConfig() + await this.refreshWalletAccounts() + await this.refreshAddresses() + await this.scanAddressWithAmount() + } + } +}) diff --git a/lnbits/extensions/watchonly/static/js/map.js b/lnbits/extensions/watchonly/static/js/map.js new file mode 100644 index 00000000..e5eec90a --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/map.js @@ -0,0 +1,81 @@ +const mapAddressesData = a => ({ + id: a.id, + address: a.address, + amount: a.amount, + wallet: a.wallet, + note: a.note, + + branchIndex: a.branch_index, + addressIndex: a.address_index, + hasActivity: a.has_activity +}) + +const mapInputToSentHistory = (tx, addressData, vin) => ({ + sent: true, + txId: tx.txid, + address: addressData.address, + isChange: addressData.branchIndex === 1, + amount: vin.prevout.value, + date: blockTimeToDate(tx.status.block_time), + height: tx.status.block_height, + confirmed: tx.status.confirmed, + fee: tx.fee, + expanded: false +}) + +const mapOutputToReceiveHistory = (tx, addressData, vout) => ({ + received: true, + txId: tx.txid, + address: addressData.address, + isChange: addressData.branchIndex === 1, + amount: vout.value, + date: blockTimeToDate(tx.status.block_time), + height: tx.status.block_height, + confirmed: tx.status.confirmed, + fee: tx.fee, + expanded: false +}) + +const mapUtxoToPsbtInput = utxo => ({ + tx_id: utxo.txId, + vout: utxo.vout, + amount: utxo.amount, + address: utxo.address, + branch_index: utxo.branchIndex, + address_index: utxo.addressIndex, + masterpub_fingerprint: utxo.masterpubFingerprint, + accountType: utxo.accountType, + txHex: '' +}) + +const mapAddressDataToUtxo = (wallet, addressData, utxo) => ({ + id: addressData.id, + address: addressData.address, + isChange: addressData.branchIndex === 1, + addressIndex: addressData.addressIndex, + branchIndex: addressData.branchIndex, + wallet: addressData.wallet, + accountType: addressData.accountType, + masterpubFingerprint: wallet.fingerprint, + txId: utxo.txid, + vout: utxo.vout, + confirmed: utxo.status.confirmed, + amount: utxo.value, + date: blockTimeToDate(utxo.status?.block_time), + sort: utxo.status?.block_time, + expanded: false, + 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 +} diff --git a/lnbits/extensions/watchonly/static/js/tables.js b/lnbits/extensions/watchonly/static/js/tables.js new file mode 100644 index 00000000..fdd558bd --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/tables.js @@ -0,0 +1,277 @@ +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: [ + { + name: 'totalInputs', + align: 'center', + label: 'Selected Amount' + }, + { + name: 'totalOutputs', + align: 'center', + label: 'Payed Amount' + }, + { + name: 'fees', + align: 'center', + label: 'Fees' + }, + { + name: 'change', + align: 'center', + 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, + psbtBase64: '', + utxoSelectionModes: [ + 'Manual', + 'Random', + 'Select All', + 'Smaller Inputs First', + 'Larger Inputs First' + ], + utxoSelectionMode: 'Manual', + show: false, + showAdvanced: false + }, + summary: { + data: [{totalInputs: 0, totalOutputs: 0, fees: 0, change: 0}] + } +} diff --git a/lnbits/extensions/watchonly/static/js/utils.js b/lnbits/extensions/watchonly/static/js/utils.js new file mode 100644 index 00000000..d2f60399 --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/utils.js @@ -0,0 +1,99 @@ +const blockTimeToDate = blockTime => + blockTime ? moment(blockTime * 1000).format('LLL') : '' + +const currentDateTime = () => moment().format('LLL') + +const sleep = ms => new Promise(r => setTimeout(r, ms)) + +const retryWithDelay = async function (fn, retryCount = 0) { + try { + await sleep(25) + // Do not return the call directly, use result. + // Otherwise the error will not be cought in this try-catch block. + const result = await fn() + return result + } catch (err) { + if (retryCount > 100) throw err + await sleep((retryCount + 1) * 1000) + return retryWithDelay(fn, retryCount + 1) + } +} + +const txSize = tx => { + // https://bitcoinops.org/en/tools/calc-size/ + // overhead size + const nVersion = 4 + const inCount = 1 + const outCount = 1 + const nlockTime = 4 + const hasSegwit = !!tx.inputs.find(inp => + ['p2wsh', 'p2wpkh', 'p2tr'].includes(inp.accountType) + ) + const segwitFlag = hasSegwit ? 0.5 : 0 + const overheadSize = nVersion + inCount + outCount + nlockTime + segwitFlag + + // inputs size + const outpoint = 36 // txId plus vout index number + const scriptSigLength = 1 + const nSequence = 4 + const inputsSize = tx.inputs.reduce((t, inp) => { + const scriptSig = + inp.accountType === 'p2pkh' ? 107 : inp.accountType === 'p2sh' ? 254 : 0 + const witnessItemCount = hasSegwit ? 0.25 : 0 + const witnessItems = + inp.accountType === 'p2wpkh' + ? 27 + : inp.accountType === 'p2wsh' + ? 63.5 + : inp.accountType === 'p2tr' + ? 16.5 + : 0 + t += + outpoint + + scriptSigLength + + nSequence + + scriptSig + + witnessItemCount + + witnessItems + return t + }, 0) + + // outputs size + const nValue = 8 + const scriptPubKeyLength = 1 + + const outputsSize = tx.outputs.reduce((t, out) => { + const type = guessAddressType(out.address) + + const scriptPubKey = + type === 'p2pkh' + ? 25 + : type === 'p2wpkh' + ? 22 + : type === 'p2sh' + ? 23 + : type === 'p2wsh' + ? 34 + : 34 // default to the largest size (p2tr included) + t += nValue + scriptPubKeyLength + scriptPubKey + return t + }, 0) + + return overheadSize + inputsSize + outputsSize +} +const guessAddressType = (a = '') => { + if (a.startsWith('1') || a.startsWith('n')) return 'p2pkh' + if (a.startsWith('3') || a.startsWith('2')) return 'p2sh' + if (a.startsWith('bc1q') || a.startsWith('tb1q')) + return a.length === 42 ? 'p2wpkh' : 'p2wsh' + if (a.startsWith('bc1p') || a.startsWith('tb1p')) return 'p2tr' +} +// bc1qwpdwyaqweavs5fpa8rujqaxmrmt9lvmydmcnt7 +const ACCOUNT_TYPES = { + p2tr: 'Taproot, BIP86, P2TR, Bech32m', + p2wpkh: 'SegWit, BIP84, P2WPKH, Bech32', + p2sh: 'BIP49, P2SH-P2WPKH, Base58', + p2pkh: 'Legacy, BIP44, P2PKH, Base58' +} + +const getAccountDescription = type => ACCOUNT_TYPES[type] || 'nonstandard' diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html index e70f8a23..dec66760 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/index.html +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -3,36 +3,36 @@
- - {% raw %} - New wallet - - -
- Point to another Mempool - {{ this.mempool.endpoint }} - - -
- set - cancel -
-
+ {% raw %} +
+
+
+
{{satBtc(utxos.total)}}
- - +
+
+ + +
+
-
Wallets
+ Add Wallet Account +
-
+ +
+
@@ -106,70 +172,862 @@ -
-
{{satBtc(utxos.total)}}
- - {{utxos.sats ? ' sats' : ' BTC'}} +
+
+ Scan Blockchain + +
+
+ Make Payment +
+
+ +
+
-
-
-
Transactions
-
-
- + + + + + + +
+
+ +
+
+ +
+
+ + + +
+
+ -