feat: add on-chain watch-only functionality

This commit is contained in:
Vlad Stan
2022-07-28 15:04:13 +03:00
parent 92e7dde500
commit c1289c43a4
9 changed files with 2698 additions and 614 deletions
+169 -100
View File
@@ -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,)