Merge pull request #699 from motorina0/ext_watch_only_phase_2

onchain watch-only extension - phase 2
This commit is contained in:
Arc
2022-07-28 13:22:51 +01:00
committed by GitHub
19 changed files with 2897 additions and 881 deletions

View File

@ -225,7 +225,8 @@
<div v-else>
<q-checkbox :value="false" label="Onchain" disabled>
<q-tooltip>
Watch-Only extension MUST be activated and have a wallet
Onchain Wallet (watch-only) extension MUST be activated and
have a wallet
</q-tooltip>
</q-checkbox>
</div>

View File

@ -18,7 +18,7 @@ In the "Whitelist Users" field, input the username of a Twitch account you contr
For now, simply set the "Redirect URI" to `http://localhost`, you will change this soon.
Then, hit create:
![image](https://user-images.githubusercontent.com/28876473/127759264-ae91539a-5694-4096-a478-80eb02b7b594.png)
1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Watch Only (to accept on-chain donations) extenions:
1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Onchain Wallet (watch-only) (to accept on-chain donations) extenions:
![image](https://user-images.githubusercontent.com/28876473/127759486-0e3420c2-c498-4bf9-932e-0abfa17bd478.png)
1. Create a "NEW SERVICE" using the button. Fill in all the information (you get your Client ID and Secret from the Streamlabs App page):
![image](https://user-images.githubusercontent.com/28876473/127759512-8e8b4e90-2a64-422a-bf0a-5508d0630bed.png)

View File

@ -168,7 +168,8 @@
<div v-else>
<q-checkbox :value="false" label="Chain" disabled>
<q-tooltip>
Watch-Only extension MUST be activated and have a wallet
Onchain Wallet (watch-only) extension MUST be activated and
have a wallet
</q-tooltip>
</q-checkbox>
</div>

View File

@ -1,19 +1,85 @@
# Watch Only wallet
# Onchain Wallet (watch-only)
## Monitor an onchain wallet and generate addresses for onchain payments
Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API.
1. Start by clicking "NEW WALLET"\
![new wallet](https://i.imgur.com/vgbAB7c.png)
2. Fill the requested fields:
- give the wallet a name
- paste an Extended Public Key (xpub, ypub, zpub)
- click "CREATE WATCH-ONLY WALLET"\
![fill wallet form](https://i.imgur.com/UVoG7LD.png)
3. You can then access your onchain addresses\
![get address](https://i.imgur.com/zkxTQ6l.png)
4. You can then generate bitcoin onchain adresses from LNbits\
![onchain address](https://i.imgur.com/4KVSSJn.png)
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
### Wallet Account
- a user can add one or more `xPubs` or `descriptors`
- the `xPub` fingerprint 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`)
- 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`)
### Scan Blockchain
- when the user clicks `Scan Blockchain`, the wallet will loop over the all addresses (for each account)
- if funds are found, then the list is extended
- will scan addresses for all wallet accounts
- the search is done on the client-side (using the `mempool.space` API). `mempool.space` has a limit on the number of req/sec, therefore it is expected for the scanning to start fast, but slow down as more HTTP requests have to be retried
- addresses can also be rescanned individually form the `Address Details` section (`Addresses` tab) of each address
### New Receive Address
- the `New Receive Address` button show the user the NEXT un-used address
- un-used means funds have not already been sent to that address AND the address has not already been shared
- internally there is a counter that keeps track of the last shared address
- it is possible to add a `Note` to each address in order to remember when/with whom it was shared
- mind the gap (`screenshot 4`)
### Addresses Tab
- the `Addresses` tab contains a list with the addresses for all the `Wallet Accounts`
- only one entry per address will be shown (even if there are multiple UTXOs at that address)
- several filter criteria can be applied
- unconfirmed funds are also taken into account
- `Address Details` can be viewed by clicking the `Expand` button
### History Tap
- shows the chronological order of transactions
- it shows unconfirmed transactions at the top
- it can be exported as CSV file
### Coins Tab
- shows the UTXOs for all wallets
- there can be multiple UTXOs for the same address
### Make 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
### 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`)
## Screensots
- screenshot 1:
![image](https://user-images.githubusercontent.com/2951406/177181611-eeeac70c-c245-4b45-b80b-8bbb511f6d1d.png)
- screenshot 2:
![image](https://user-images.githubusercontent.com/2951406/177331468-f9b43626-548a-4608-b0d0-44007f402404.png)
- screenshot 3:
![image](https://user-images.githubusercontent.com/2951406/177333755-4a9118fb-3eaf-43d6-bc7e-c3d8c80bc61e.png)
- screenshot 4:
![image](https://user-images.githubusercontent.com/2951406/177337474-bfcf7a7c-501a-4ebb-916e-ca391e63f6a7.png)

View File

@ -1,10 +1,18 @@
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_watchonly")
watchonly_static_files = [
{
"path": "/watchonly/static",
"app": StaticFiles(directory="lnbits/extensions/watchonly/static"),
"name": "watchonly_static",
}
]
watchonly_ext: APIRouter = APIRouter(prefix="/watchonly", tags=["watchonly"])

View File

@ -1,8 +1,9 @@
{
"name": "Watch Only",
"name": "Onchain Wallet",
"short_description": "Onchain watch only wallets",
"icon": "visibility",
"contributors": [
"arcbtc"
"arcbtc",
"motorina0"
]
}

View File

@ -1,81 +1,16 @@
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 .helpers import derive_address, parse_key
from .models import Address, Config, Mempool, WalletAccount
##########################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:
# check the masterpub is fine, it will raise an exception if not
parse_key(masterpub)
async def create_watch_wallet(w: WalletAccount) -> WalletAccount:
wallet_id = urlsafe_short_hash()
await db.execute(
"""
@ -83,34 +18,44 @@ async def create_watch_wallet(user: str, masterpub: str, title: str) -> 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,
w.user,
w.masterpub,
w.fingerprint,
w.title,
w.type,
w.address_no,
w.balance,
),
)
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,65 +64,184 @@ 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:
await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,))
########################ADDRESSES#######################
########################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]:
# todo: move logic to views_api after satspay refactoring
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.has_activity, 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 +256,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 +270,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,)

View File

@ -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)

View File

@ -34,3 +34,50 @@ async def m001_initial(db):
);
"""
)
async def m002_add_columns_to_adresses(db):
"""
Add 'branch_index', 'address_index', 'has_activity' and 'note' columns to the 'addresses' table
"""
await db.execute(
"ALTER TABLE watchonly.addresses ADD COLUMN branch_index INTEGER NOT NULL DEFAULT 0;"
)
await db.execute(
"ALTER TABLE watchonly.addresses ADD COLUMN address_index INTEGER NOT NULL DEFAULT 0;"
)
await db.execute(
"ALTER TABLE watchonly.addresses ADD COLUMN has_activity BOOLEAN DEFAULT false;"
)
await db.execute("ALTER TABLE watchonly.addresses ADD COLUMN note TEXT;")
async def m003_add_columns_to_wallets(db):
"""
Add 'type' and 'fingerprint' columns to the 'wallets' table
"""
await db.execute("ALTER TABLE watchonly.wallets ADD COLUMN type TEXT;")
await db.execute(
"ALTER TABLE watchonly.wallets ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';"
)
async def m004_create_config_table(db):
"""
Allow the extension to persist and retrieve any number of config values.
Each user has its configurations saved as a JSON string
"""
await db.execute(
"""CREATE TABLE watchonly.config (
"user" TEXT NOT NULL,
json_data TEXT NOT NULL
);"""
)
### TODO: fix statspay dependcy first
# await db.execute(
# "DROP TABLE watchonly.wallets;"
# )

View File

@ -1,4 +1,5 @@
from sqlite3 import Row
from typing import List
from fastapi.param_functions import Query
from pydantic import BaseModel
@ -9,19 +10,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 +35,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

View File

@ -0,0 +1,735 @@
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()
await this.refreshAddresses()
if (!this.payment.changeWallett) {
this.payment.changeWallet = this.walletAccounts[0]
this.selectChangeAddress(this.payment.changeWallet)
}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteWalletAccount: function (walletAccountId) {
LNbits.utils
.confirmDialog(
'Are you sure you want to delete this watch only wallet?'
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/watchonly/api/v1/wallet/' + walletAccountId,
this.g.user.wallets[0].adminkey
)
this.walletAccounts = _.reject(this.walletAccounts, function (obj) {
return obj.id === walletAccountId
})
await this.refreshWalletAccounts()
await this.refreshAddresses()
if (
this.payment.changeWallet &&
this.payment.changeWallet.id === walletAccountId
) {
this.payment.changeWallet = this.walletAccounts[0]
this.selectChangeAddress(this.payment.changeWallet)
}
await this.scanAddressWithAmount()
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Error while deleting wallet account. Please try again.',
timeout: 10000
})
}
})
},
getAddressesForWallet: async function (walletId) {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/addresses/' + walletId,
this.g.user.wallets[0].inkey
)
return data.map(mapAddressesData)
} catch (err) {
this.$q.notify({
type: 'warning',
message: `Failed to fetch addresses for wallet with id ${walletId}.`,
timeout: 10000
})
LNbits.utils.notifyApiError(err)
}
return []
},
getWatchOnlyWallets: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/wallet',
this.g.user.wallets[0].inkey
)
return data
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to fetch wallets.',
timeout: 10000
})
LNbits.utils.notifyApiError(error)
}
return []
},
refreshWalletAccounts: async function () {
const wallets = await this.getWatchOnlyWallets()
this.walletAccounts = wallets.map(w => mapWalletAccount(w))
},
getAmmountForWallet: function (walletId) {
const amount = this.addresses.data
.filter(a => a.wallet === walletId)
.reduce((t, a) => t + a.amount || 0, 0)
return this.satBtc(amount)
},
//################### ADDRESSES ###################
refreshAddresses: async function () {
const wallets = await this.getWatchOnlyWallets()
this.addresses.data = []
for (const {id, type} of wallets) {
const newAddresses = await this.getAddressesForWallet(id)
const uniqueAddresses = newAddresses.filter(
newAddr =>
!this.addresses.data.find(a => a.address === newAddr.address)
)
const lastAcctiveAddress =
uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() || {}
uniqueAddresses.forEach(a => {
a.expanded = false
a.accountType = type
a.gapLimitExceeded =
!a.isChange &&
a.addressIndex >
lastAcctiveAddress.addressIndex +
this.config.DEFAULT_RECEIVE_GAP_LIMIT
})
this.addresses.data.push(...uniqueAddresses)
}
},
updateAmountForAddress: async function (addressData, amount = 0) {
try {
const wallet = this.g.user.wallets[0]
addressData.amount = amount
if (!addressData.isChange) {
const addressWallet = this.walletAccounts.find(
w => w.id === addressData.wallet
)
if (
addressWallet &&
addressWallet.address_no < addressData.addressIndex
) {
addressWallet.address_no = addressData.addressIndex
}
}
await LNbits.api.request(
'PUT',
`/watchonly/api/v1/address/${addressData.id}`,
wallet.adminkey,
{amount}
)
} catch (err) {
addressData.error = 'Failed to refresh amount for address'
this.$q.notify({
type: 'warning',
message: `Failed to refresh amount for address ${addressData.address}`,
timeout: 10000
})
LNbits.utils.notifyApiError(err)
}
},
updateNoteForAddress: async function (addressData, note) {
try {
const wallet = this.g.user.wallets[0]
await LNbits.api.request(
'PUT',
`/watchonly/api/v1/address/${addressData.id}`,
wallet.adminkey,
{note: addressData.note}
)
const updatedAddress =
this.addresses.data.find(a => a.id === addressData.id) || {}
updatedAddress.note = note
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
getFilteredAddresses: function () {
const selectedWalletId = this.addresses.selectedWallet?.id
const filter = this.addresses.filterValues || []
const includeChangeAddrs = filter.includes('Show Change Addresses')
const includeGapAddrs = filter.includes('Show Gap Addresses')
const excludeNoAmount = filter.includes('Only With Amount')
const walletsLimit = this.walletAccounts.reduce((r, w) => {
r[`_${w.id}`] = w.address_no
return r
}, {})
const addresses = this.addresses.data.filter(
a =>
(includeChangeAddrs || !a.isChange) &&
(includeGapAddrs ||
a.isChange ||
a.addressIndex <= walletsLimit[`_${a.wallet}`]) &&
!(excludeNoAmount && a.amount === 0) &&
(!selectedWalletId || a.wallet === selectedWalletId)
)
return addresses
},
openGetFreshAddressDialog: async function (walletId) {
const {data} = await LNbits.api.request(
'GET',
`/watchonly/api/v1/address/${walletId}`,
this.g.user.wallets[0].inkey
)
const addressData = mapAddressesData(data)
addressData.note = `Shared on ${currentDateTime()}`
const lastAcctiveAddress =
this.addresses.data
.filter(
a => a.wallet === addressData.wallet && !a.isChange && a.hasActivity
)
.pop() || {}
addressData.gapLimitExceeded =
!addressData.isChange &&
addressData.addressIndex >
lastAcctiveAddress.addressIndex +
this.config.DEFAULT_RECEIVE_GAP_LIMIT
this.openQrCodeDialog(addressData)
const wallet = this.walletAccounts.find(w => w.id === walletId) || {}
wallet.address_no = addressData.addressIndex
await this.refreshAddresses()
},
//################### ADDRESS HISTORY ###################
addressHistoryFromTxs: function (addressData, txs) {
const addressHistory = []
txs.forEach(tx => {
const sent = tx.vin
.filter(
vin => vin.prevout.scriptpubkey_address === addressData.address
)
.map(vin => mapInputToSentHistory(tx, addressData, vin))
const received = tx.vout
.filter(vout => vout.scriptpubkey_address === addressData.address)
.map(vout => mapOutputToReceiveHistory(tx, addressData, vout))
addressHistory.push(...sent, ...received)
})
return addressHistory
},
getFilteredAddressesHistory: function () {
return this.addresses.history.filter(
a => (!a.isChange || a.sent) && !a.isSubItem
)
},
exportHistoryToCSV: function () {
const history = this.getFilteredAddressesHistory().map(a => ({
...a,
action: a.sent ? 'Sent' : 'Received'
}))
LNbits.utils.exportCSV(
this.historyTable.exportColums,
history,
'address-history'
)
},
markSameTxAddressHistory: function () {
this.addresses.history
.filter(s => s.sent)
.forEach((el, i, arr) => {
if (el.isSubItem) return
const sameTxItems = arr.slice(i + 1).filter(e => e.txId === el.txId)
if (!sameTxItems.length) return
sameTxItems.forEach(e => {
e.isSubItem = true
})
el.totalAmount =
el.amount + sameTxItems.reduce((t, e) => (t += e.amount || 0), 0)
el.sameTxItems = sameTxItems
})
},
showAddressHistoryDetails: function (addressHistory) {
addressHistory.expanded = true
},
//################### PAYMENT ###################
createTx: function (excludeChange = false) {
const tx = {
fee_rate: this.payment.feeRate,
tx_size: this.payment.txSize,
masterpubs: this.walletAccounts.map(w => ({
public_key: w.masterpub,
fingerprint: w.fingerprint
}))
}
tx.inputs = this.utxos.data
.filter(utxo => utxo.selected)
.map(mapUtxoToPsbtInput)
.sort((a, b) =>
a.tx_id < b.tx_id ? -1 : a.tx_id > b.tx_id ? 1 : a.vout - b.vout
)
tx.outputs = this.payment.data.map(out => ({
address: out.address,
amount: out.amount
}))
if (excludeChange) {
this.payment.changeAmount = 0
} else {
const change = this.createChangeOutput()
this.payment.changeAmount = change.amount
if (change.amount >= this.DUST_LIMIT) {
tx.outputs.push(change)
}
}
// Only sort by amount on UI level (no lib for address decode)
// Should sort by scriptPubKey (as byte array) on the backend
tx.outputs.sort((a, b) => a.amount - b.amount)
return tx
},
createChangeOutput: function () {
const change = this.payment.changeAddress
const fee = this.payment.feeRate * this.payment.txSize
const inputAmount = this.getTotalSelectedUtxoAmount()
const payedAmount = this.getTotalPaymentAmount()
const walletAcount =
this.walletAccounts.find(w => w.id === change.wallet) || {}
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++) {
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()
}
}
})

View File

@ -0,0 +1,80 @@
const mapAddressesData = a => ({
id: a.id,
address: a.address,
amount: a.amount,
wallet: a.wallet,
note: a.note,
isChange: a.branch_index === 1,
addressIndex: a.address_index,
hasActivity: a.has_activity
})
const mapInputToSentHistory = (tx, addressData, vin) => ({
sent: true,
txId: tx.txid,
address: addressData.address,
isChange: addressData.isChange,
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.isChange,
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.isChange ? 1 : 0,
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.isChange,
addressIndex: addressData.addressIndex,
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
}

View File

@ -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}]
}
}

View File

@ -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'
}
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'

View File

@ -1,248 +1,27 @@
<q-card>
<q-card-section>
<p>
Watch Only extension uses mempool.space<br />
Onchain Wallet (watch-only) extension uses mempool.space<br />
For use with "account Extended Public Key"
<a href="https://iancoleman.io/bip39/">https://iancoleman.io/bip39/</a>
<small>
<br />Created by,
<a target="_blank" href="https://github.com/arcbtc">Ben Arc</a> (using,
<a target="_blank" href="https://github.com/diybitcoinhardware/embit"
<a target="_blank" class="text-white" href="https://github.com/arcbtc"
>Ben Arc</a
>
(using,
<a
target="_blank"
class="text-white"
href="https://github.com/diybitcoinhardware/embit"
>Embit</a
></small
>)
<br />
<br />
<a target="_blank" href="/docs#/watchonly" class="text-white"
>Swagger REST API Documentation</a
>
</p>
</q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/watchonly"></q-btn>
<q-expansion-item group="api" dense expand-separator label="List wallets">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /watchonly/api/v1/wallet</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;wallets_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}watchonly/api/v1/wallet -H
"X-Api-Key: {{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get wallet details"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/watchonly/api/v1/wallet/&lt;wallet_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>[&lt;wallet_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}watchonly/api/v1/wallet/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create wallet">
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span> /watchonly/api/v1/wallet</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>[&lt;wallet_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}watchonly/api/v1/wallet -d
'{"title": &lt;string&gt;, "masterpub": &lt;string&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete wallet"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/watchonly/api/v1/wallet/&lt;wallet_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}watchonly/api/v1/wallet/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="List addresses">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/watchonly/api/v1/addresses/&lt;wallet_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;address_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}watchonly/api/v1/addresses/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get fresh address"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/watchonly/api/v1/address/&lt;wallet_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;address_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}watchonly/api/v1/address/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get mempool.space details"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /watchonly/api/v1/mempool</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;mempool_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}watchonly/api/v1/mempool -H
"X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Update mempool.space"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/watchonly/api/v1/mempool</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>[&lt;mempool_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.base_url }}watchonly/api/v1/mempool -d
'{"endpoint": &lt;string&gt;}' -H "Content-type: application/json"
-H "X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,11 @@
from http import HTTPStatus
from fastapi import Query
from embit import script
from embit.descriptor import Descriptor, Key
from embit.ec import PublicKey
from embit.psbt import PSBT, DerivationPath
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from fastapi import Query, Request
from fastapi.params import Depends
from starlette.exceptions import HTTPException
@ -8,17 +13,25 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.extensions.watchonly import watchonly_ext
from .crud import (
create_config,
create_fresh_addresses,
create_mempool,
create_watch_wallet,
delete_addresses_for_wallet,
delete_watch_wallet,
get_addresses,
get_config,
get_fresh_address,
get_mempool,
get_watch_wallet,
get_watch_wallets,
update_address,
update_config,
update_mempool,
update_watch_wallet,
)
from .models import CreateWallet
from .helpers import parse_key
from .models import Config, CreatePsbt, CreateWallet, WalletAccount
###################WALLETS#############################
@ -48,18 +61,42 @@ async def api_wallet_retrieve(
@watchonly_ext.post("/api/v1/wallet")
async def api_wallet_create_or_update(
data: CreateWallet, wallet_id=None, w: WalletTypeInfo = Depends(require_admin_key)
data: CreateWallet, w: WalletTypeInfo = Depends(require_admin_key)
):
try:
wallet = await create_watch_wallet(
user=w.wallet.user, masterpub=data.masterpub, title=data.title
(descriptor, _) = parse_key(data.masterpub)
new_wallet = WalletAccount(
id="none",
user=w.wallet.user,
masterpub=data.masterpub,
fingerprint=descriptor.keys[0].fingerprint.hex(),
type=descriptor.scriptpubkey_type(),
title=data.title,
address_no=-1, # so fresh address on empty wallet can get address with index 0
balance=0,
)
wallets = await get_watch_wallets(w.wallet.user)
existing_wallet = next(
(ew for ew in wallets if ew.fingerprint == new_wallet.fingerprint), None
)
if existing_wallet:
raise ValueError(
"Account '{}' has the same master pulic key".format(
existing_wallet.title
)
)
wallet = await create_watch_wallet(new_wallet)
await api_get_addresses(wallet.id, w)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
mempool = await get_mempool(w.wallet.user)
if not mempool:
create_mempool(user=w.wallet.user)
config = await get_config(w.wallet.user)
if not config:
await create_config(user=w.wallet.user)
return wallet.dict()
@ -73,6 +110,7 @@ async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin
)
await delete_watch_wallet(wallet_id)
await delete_addresses_for_wallet(wallet_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@ -83,31 +121,171 @@ async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin
@watchonly_ext.get("/api/v1/address/{wallet_id}")
async def api_fresh_address(wallet_id, w: WalletTypeInfo = Depends(get_key_type)):
address = await get_fresh_address(wallet_id)
return address.dict()
return [address.dict()]
@watchonly_ext.put("/api/v1/address/{id}")
async def api_update_address(
id: str, req: Request, w: WalletTypeInfo = Depends(require_admin_key)
):
body = await req.json()
params = {}
# amout is only updated if the address has history
if "amount" in body:
params["amount"] = int(body["amount"])
params["has_activity"] = True
if "note" in body:
params["note"] = str(body["note"])
address = await update_address(**params, id=id)
wallet = (
await get_watch_wallet(address.wallet)
if address.branch_index == 0 and address.amount != 0
else None
)
if wallet and wallet.address_no < address.address_index:
await update_watch_wallet(
address.wallet, **{"address_no": address.address_index}
)
return address
@watchonly_ext.get("/api/v1/addresses/{wallet_id}")
async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)):
wallet = await get_watch_wallet(wallet_id)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
addresses = await get_addresses(wallet_id)
config = await get_config(w.wallet.user)
if not addresses:
await get_fresh_address(wallet_id)
await create_fresh_addresses(wallet_id, 0, config.receive_gap_limit)
await create_fresh_addresses(wallet_id, 0, config.change_gap_limit, True)
addresses = await get_addresses(wallet_id)
receive_addresses = list(filter(lambda addr: addr.branch_index == 0, addresses))
change_addresses = list(filter(lambda addr: addr.branch_index == 1, addresses))
last_receive_address = list(
filter(lambda addr: addr.has_activity, receive_addresses)
)[-1:]
last_change_address = list(
filter(lambda addr: addr.has_activity, change_addresses)
)[-1:]
if last_receive_address:
current_index = receive_addresses[-1].address_index
address_index = last_receive_address[0].address_index
await create_fresh_addresses(
wallet_id, current_index + 1, address_index + config.receive_gap_limit + 1
)
if last_change_address:
current_index = change_addresses[-1].address_index
address_index = last_change_address[0].address_index
await create_fresh_addresses(
wallet_id,
current_index + 1,
address_index + config.change_gap_limit + 1,
True,
)
addresses = await get_addresses(wallet_id)
return [address.dict() for address in addresses]
#############################PSBT##########################
@watchonly_ext.post("/api/v1/psbt")
async def api_psbt_create(
data: CreatePsbt, w: WalletTypeInfo = Depends(require_admin_key)
):
try:
vin = [
TransactionInput(bytes.fromhex(inp.tx_id), inp.vout) for inp in data.inputs
]
vout = [
TransactionOutput(out.amount, script.address_to_scriptpubkey(out.address))
for out in data.outputs
]
descriptors = {}
for _, masterpub in enumerate(data.masterpubs):
descriptors[masterpub.fingerprint] = parse_key(masterpub.public_key)
inputs_extra = []
bip32_derivations = {}
for i, inp in enumerate(data.inputs):
descriptor = descriptors[inp.masterpub_fingerprint][0]
d = descriptor.derive(inp.address_index, inp.branch_index)
for k in d.keys:
bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath(
k.origin.fingerprint, k.origin.derivation
)
inputs_extra.append(
{
"bip32_derivations": bip32_derivations,
"non_witness_utxo": Transaction.from_string(inp.tx_hex),
}
)
tx = Transaction(vin=vin, vout=vout)
psbt = PSBT(tx)
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)
outputs_extra = []
bip32_derivations = {}
for i, out in enumerate(data.outputs):
if out.branch_index == 1:
descriptor = descriptors[out.masterpub_fingerprint][0]
d = descriptor.derive(out.address_index, out.branch_index)
for k in d.keys:
bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath(
k.origin.fingerprint, k.origin.derivation
)
outputs_extra.append({"bip32_derivations": bip32_derivations})
for i, out in enumerate(outputs_extra):
psbt.outputs[i].bip32_derivations = out["bip32_derivations"]
return psbt.to_string()
except Exception as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
#############################CONFIG##########################
@watchonly_ext.put("/api/v1/config")
async def api_update_config(
data: Config, w: WalletTypeInfo = Depends(require_admin_key)
):
config = await update_config(data, user=w.wallet.user)
return config.dict()
@watchonly_ext.get("/api/v1/config")
async def api_get_config(w: WalletTypeInfo = Depends(get_key_type)):
config = await get_config(w.wallet.user)
if not config:
config = await create_config(user=w.wallet.user)
return config.dict()
#############################MEMPOOL##########################
### TODO: fix statspay dependcy and remove
@watchonly_ext.put("/api/v1/mempool")
async def api_update_mempool(
endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key)
@ -116,6 +294,7 @@ async def api_update_mempool(
return mempool.dict()
### TODO: fix statspay dependcy and remove
@watchonly_ext.get("/api/v1/mempool")
async def api_get_mempool(w: WalletTypeInfo = Depends(require_admin_key)):
mempool = await get_mempool(w.wallet.user)

View File

@ -261,7 +261,7 @@ window.LNbits = {
return data
}
},
exportCSV: function (columns, data) {
exportCSV: function (columns, data, fileName) {
var wrapCsvValue = function (val, formatFn) {
var formatted = formatFn !== void 0 ? formatFn(val) : val
@ -295,7 +295,7 @@ window.LNbits = {
.join('\r\n')
var status = Quasar.utils.exportFile(
'table-export.csv',
`${fileName || 'table-export'}.csv`,
content,
'text/csv'
)

View File

@ -283,22 +283,24 @@ def migrate_ext(sqlite_db_file, schema, ignore_missing=True):
masterpub,
title,
address_no,
balance
balance,
type,
fingerprint
)
VALUES (%s, %s, %s, %s, %s, %s);
VALUES (%s, %s, %s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# ADDRESSES
res = sq.execute("SELECT * FROM addresses;")
q = f"""
INSERT INTO watchonly.addresses (id, address, wallet, amount)
VALUES (%s, %s, %s, %s);
INSERT INTO watchonly.addresses (id, address, wallet, amount, branch_index, address_index, has_activity, note)
VALUES (%s, %s, %s, %s, %s, %s, %s::boolean, %s);
"""
insert_to_pg(q, res.fetchall())
# MEMPOOL
res = sq.execute("SELECT * FROM mempool;")
# CONFIG
res = sq.execute("SELECT * FROM config;")
q = f"""
INSERT INTO watchonly.mempool ("user", endpoint)
INSERT INTO watchonly.config ("user", json_data)
VALUES (%s, %s);
"""
insert_to_pg(q, res.fetchall())