import os import json import logging from binascii import unhexlify from datetime import datetime from typing import Optional from lbrynet.schema.legacy_schema_v1 import SECP256k1 from torba.client.basemanager import BaseWalletManager from torba.rpc.jsonrpc import CodeMessageError from lbrynet.schema.claim import ClaimDict from lbrynet.extras.wallet.ledger import MainNetLedger from lbrynet.extras.wallet.account import BaseAccount, generate_certificate from lbrynet.extras.wallet.transaction import Transaction from lbrynet.extras.wallet.database import WalletDatabase from lbrynet.extras.wallet.dewies import dewies_to_lbc log = logging.getLogger(__name__) class ReservedPoints: def __init__(self, identifier, amount): self.identifier = identifier self.amount = amount class BackwardsCompatibleNetwork: def __init__(self, manager): self.manager = manager def get_local_height(self): for ledger in self.manager.ledgers.values(): assert isinstance(ledger, MainNetLedger) return ledger.headers.height def get_server_height(self): return self.get_local_height() class LbryWalletManager(BaseWalletManager): @property def ledger(self) -> MainNetLedger: return self.default_account.ledger @property def db(self) -> WalletDatabase: return self.ledger.db @property def wallet(self): return self @property def network(self): return BackwardsCompatibleNetwork(self) @property def use_encryption(self): return self.default_account.serialize_encrypted @property def is_first_run(self): return True @property def is_wallet_unlocked(self): return not self.default_account.encrypted def check_locked(self): return self.default_account.encrypted def decrypt_account(self, account): assert account.password is not None, "account is not unlocked" assert not account.encrypted, "account is not unlocked" account.serialize_encrypted = False self.save() return not account.encrypted and not account.serialize_encrypted def encrypt_account(self, password, account): assert not account.encrypted, "account is already encrypted" account.encrypt(password) account.serialize_encrypted = True self.save() self.unlock_account(password, account) return account.serialize_encrypted def unlock_account(self, password, account): assert account.encrypted, "account is not locked" account.decrypt(password) return not account.encrypted def lock_account(self, account): assert account.password is not None, "account is already locked" assert not account.encrypted and account.serialize_encrypted, "account is not encrypted" account.encrypt(account.password) return account.encrypted @staticmethod def migrate_lbryum_to_torba(path): if not os.path.exists(path): return None, None with open(path, 'r') as f: unmigrated_json = f.read() unmigrated = json.loads(unmigrated_json) # TODO: After several public releases of new torba based wallet, we can delete # this lbryum->torba conversion code and require that users who still # have old structured wallets install one of the earlier releases that # still has the below conversion code. if 'master_public_keys' not in unmigrated: return None, None total = unmigrated.get('addr_history') receiving_addresses, change_addresses = set(), set() for _, unmigrated_account in unmigrated.get('accounts', {}).items(): receiving_addresses.update(map(unhexlify, unmigrated_account.get('receiving', []))) change_addresses.update(map(unhexlify, unmigrated_account.get('change', []))) log.info("Wallet migrator found %s receiving addresses and %s change addresses. %s in total on history.", len(receiving_addresses), len(change_addresses), len(total)) migrated_json = json.dumps({ 'version': 1, 'name': 'My Wallet', 'accounts': [{ 'version': 1, 'name': 'Main Account', 'ledger': 'lbc_mainnet', 'encrypted': unmigrated['use_encryption'], 'seed': unmigrated['seed'], 'seed_version': unmigrated['seed_version'], 'private_key': unmigrated['master_private_keys']['x/'], 'public_key': unmigrated['master_public_keys']['x/'], 'certificates': unmigrated.get('claim_certificates', {}), 'address_generator': { 'name': 'deterministic-chain', 'receiving': {'gap': 20, 'maximum_uses_per_address': 1}, 'change': {'gap': 6, 'maximum_uses_per_address': 1} } }] }, indent=4, sort_keys=True) mode = os.stat(path).st_mode i = 1 backup_path_template = os.path.join(os.path.dirname(path), "old_lbryum_wallet") + "_%i" while os.path.isfile(backup_path_template % i): i += 1 os.rename(path, backup_path_template % i) temp_path = "{}.tmp.{}".format(path, os.getpid()) with open(temp_path, "w") as f: f.write(migrated_json) f.flush() os.fsync(f.fileno()) os.rename(temp_path, path) os.chmod(path, mode) return receiving_addresses, change_addresses @classmethod async def from_lbrynet_config(cls, settings, db): ledger_id = { 'lbrycrd_main': 'lbc_mainnet', 'lbrycrd_testnet': 'lbc_testnet', 'lbrycrd_regtest': 'lbc_regtest' }[settings.blockchain_name] ledger_config = { 'auto_connect': True, 'default_servers': settings.lbryum_servers, 'data_path': settings.wallet_dir, } wallets_directory = os.path.join(settings.wallet_dir, 'wallets') if not os.path.exists(wallets_directory): os.mkdir(wallets_directory) wallet_file_path = os.path.join(wallets_directory, 'default_wallet') receiving_addresses, change_addresses = cls.migrate_lbryum_to_torba(wallet_file_path) manager = cls.from_config({ 'ledgers': {ledger_id: ledger_config}, 'wallets': [wallet_file_path] }) ledger = manager.get_or_create_ledger(ledger_id) if manager.default_account is None: log.info('Wallet at %s is empty, generating a default account.', wallet_file_path) manager.default_wallet.generate_account(ledger) manager.default_wallet.save() if receiving_addresses or change_addresses: if not os.path.exists(ledger.path): os.mkdir(ledger.path) await ledger.db.open() try: await manager._migrate_addresses(receiving_addresses, change_addresses) finally: await ledger.db.close() return manager async def _migrate_addresses(self, receiving_addresses: set, change_addresses: set): async with self.default_account.receiving.address_generator_lock: migrated_receiving = set((await self.default_account.receiving._generate_keys(0, len(receiving_addresses)))) async with self.default_account.change.address_generator_lock: migrated_change = set((await self.default_account.change._generate_keys(0, len(change_addresses)))) receiving_addresses = set(map(self.default_account.ledger.public_key_to_address, receiving_addresses)) change_addresses = set(map(self.default_account.ledger.public_key_to_address, change_addresses)) if not any(change_addresses.difference(migrated_change)): log.info("Successfully migrated %s change addresses.", len(change_addresses)) else: log.warning("Failed to migrate %s change addresses!", len(set(change_addresses).difference(set(migrated_change)))) if not any(receiving_addresses.difference(migrated_receiving)): log.info("Successfully migrated %s receiving addresses.", len(receiving_addresses)) else: log.warning("Failed to migrate %s receiving addresses!", len(set(receiving_addresses).difference(set(migrated_receiving)))) def get_best_blockhash(self): return self.ledger.headers.hash(self.ledger.headers.height).decode() def get_unused_address(self): return self.default_account.receiving.get_or_create_usable_address() def get_new_address(self): return self.get_unused_address() def reserve_points(self, address, amount: int): # TODO: check if we have enough to cover amount return ReservedPoints(address, amount) async def send_amount_to_address(self, amount: int, destination_address: bytes, account=None): account = account or self.default_account tx = await Transaction.pay(amount, destination_address, [account], account) await account.ledger.broadcast(tx) return tx async def send_claim_to_address(self, claim_id: str, destination_address: str, amount: Optional[int], account=None): account = account or self.default_account claims = await account.get_claims( claim_name_type__any={'is_claim': 1, 'is_update': 1}, # exclude is_supports claim_id=claim_id ) if not claims: raise NameError(f"Claim not found: {claim_id}") if not amount: amount = claims[0].get_estimator(self.ledger).effective_amount tx = await Transaction.update( claims[0], ClaimDict.deserialize(claims[0].script.values['claim']), amount, destination_address.encode(), [account], account ) await self.ledger.broadcast(tx) return tx def send_points_to_address(self, reserved: ReservedPoints, amount: int, account=None): destination_address: bytes = reserved.identifier.encode('latin1') return self.send_amount_to_address(amount, destination_address, account) async def resolve(self, *uris, **kwargs): page = kwargs.get('page', 0) page_size = kwargs.get('page_size', 10) ledger: MainNetLedger = self.default_account.ledger results = await ledger.resolve(page, page_size, *uris) if 'error' not in results: await self.old_db.save_claims_for_resolve([ value for value in results.values() if 'error' not in value ]) return results async def get_claims_for_name(self, name: str): response = await self.ledger.network.get_claims_for_name(name) resolutions = await self.resolve(*(f"{claim['name']}#{claim['claim_id']}" for claim in response['claims'])) response['claims'] = [value.get('claim', value.get('certificate')) for value in resolutions.values()] return response async def address_is_mine(self, unknown_address, account): match = await self.ledger.db.get_address(address=unknown_address, account=account) if match is not None: return True return False async def get_transaction(self, txid): tx = await self.db.get_transaction(txid=txid) if not tx: try: _raw = await self.ledger.network.get_transaction(txid) except CodeMessageError as e: return {'success': False, 'code': e.code, 'message': e.message} # this is a workaround for the current protocol. Should be fixed when lbryum support is over and we # are able to use the modern get_transaction call, which accepts verbose to show height and other fields height = await self.ledger.network.get_transaction_height(txid) tx = self.ledger.transaction_class(unhexlify(_raw)) if tx and height > 0: await self.ledger.maybe_verify_transaction(tx, height + 1) # off by one from server side, yes... return tx @staticmethod async def get_history(account: BaseAccount, **constraints): headers = account.ledger.headers txs = await account.get_transactions(**constraints) history = [] for tx in txs: ts = headers[tx.height]['timestamp'] if tx.height > 0 else None item = { 'txid': tx.id, 'timestamp': ts, 'date': datetime.fromtimestamp(ts).isoformat(' ')[:-3] if tx.height > 0 else None, 'confirmations': (headers.height+1) - tx.height if tx.height > 0 else 0, 'claim_info': [], 'update_info': [], 'support_info': [], 'abandon_info': [] } is_my_inputs = all([txi.is_my_account for txi in tx.inputs]) if is_my_inputs: # fees only matter if we are the ones paying them item['value'] = dewies_to_lbc(tx.net_account_balance+tx.fee) item['fee'] = dewies_to_lbc(-tx.fee) else: # someone else paid the fees item['value'] = dewies_to_lbc(tx.net_account_balance) item['fee'] = '0.0' for txo in tx.my_claim_outputs: item['claim_info'].append({ 'address': txo.get_address(account.ledger), 'balance_delta': dewies_to_lbc(-txo.amount), 'amount': dewies_to_lbc(txo.amount), 'claim_id': txo.claim_id, 'claim_name': txo.claim_name, 'nout': txo.position }) for txo in tx.my_update_outputs: if is_my_inputs: # updating my own claim previous = None for txi in tx.inputs: if txi.txo_ref.txo is not None: other_txo = txi.txo_ref.txo if (other_txo.is_claim or other_txo.script.is_support_claim) \ and other_txo.claim_id == txo.claim_id: previous = other_txo break if previous is not None: item['update_info'].append({ 'address': txo.get_address(account.ledger), 'balance_delta': dewies_to_lbc(previous.amount-txo.amount), 'amount': dewies_to_lbc(txo.amount), 'claim_id': txo.claim_id, 'claim_name': txo.claim_name, 'nout': txo.position }) else: # someone sent us their claim item['update_info'].append({ 'address': txo.get_address(account.ledger), 'balance_delta': dewies_to_lbc(0), 'amount': dewies_to_lbc(txo.amount), 'claim_id': txo.claim_id, 'claim_name': txo.claim_name, 'nout': txo.position }) for txo in tx.my_support_outputs: item['support_info'].append({ 'address': txo.get_address(account.ledger), 'balance_delta': dewies_to_lbc(txo.amount if not is_my_inputs else -txo.amount), 'amount': dewies_to_lbc(txo.amount), 'claim_id': txo.claim_id, 'claim_name': txo.claim_name, 'is_tip': not is_my_inputs, 'nout': txo.position }) for txo in tx.other_support_outputs: item['support_info'].append({ 'address': txo.get_address(account.ledger), 'balance_delta': dewies_to_lbc(-txo.amount), 'amount': dewies_to_lbc(txo.amount), 'claim_id': txo.claim_id, 'claim_name': txo.claim_name, 'is_tip': is_my_inputs, 'nout': txo.position }) for txo in tx.my_abandon_outputs: item['abandon_info'].append({ 'address': txo.get_address(account.ledger), 'balance_delta': dewies_to_lbc(txo.amount), 'amount': dewies_to_lbc(txo.amount), 'claim_id': txo.claim_id, 'claim_name': txo.claim_name, 'nout': txo.position }) history.append(item) return history @staticmethod def get_utxos(account: BaseAccount): return account.get_utxos() async def claim_name(self, account, name, amount, claim_dict, certificate=None, claim_address=None): claim = ClaimDict.load_dict(claim_dict) if not claim_address: claim_address = await account.receiving.get_or_create_usable_address() if certificate: claim = claim.sign( certificate.private_key, claim_address, certificate.claim_id, curve=SECP256k1, name=name, force_detached=False # TODO: delete it and make True default everywhere when its out ) existing_claims = await account.get_claims( claim_name_type__any={'is_claim': 1, 'is_update': 1}, # exclude is_supports claim_name=name ) if len(existing_claims) == 0: tx = await Transaction.claim( name, claim, amount, claim_address, [account], account ) elif len(existing_claims) == 1: tx = await Transaction.update( existing_claims[0], claim, amount, claim_address, [account], account ) else: raise NameError(f"More than one other claim exists with the name '{name}'.") await account.ledger.broadcast(tx) await self.old_db.save_claims([self._old_get_temp_claim_info( tx, tx.outputs[0], claim_address, claim_dict, name, dewies_to_lbc(amount) )]) # TODO: release reserved tx outputs in case anything fails by this point return tx async def support_claim(self, claim_name, claim_id, amount, account): holding_address = await account.receiving.get_or_create_usable_address() tx = await Transaction.support(claim_name, claim_id, amount, holding_address, [account], account) await account.ledger.broadcast(tx) await self.old_db.save_supports(claim_id, [{ 'txid': tx.id, 'nout': tx.position, 'address': holding_address, 'claim_id': claim_id, 'amount': dewies_to_lbc(amount) }]) return tx async def tip_claim(self, amount, claim_id, account): claim_to_tip = await self.get_claim_by_claim_id(claim_id) tx = await Transaction.support( claim_to_tip['name'], claim_id, amount, claim_to_tip['address'], [account], account ) await account.ledger.broadcast(tx) await self.old_db.save_supports(claim_id, [{ 'txid': tx.id, 'nout': tx.position, 'address': claim_to_tip['address'], 'claim_id': claim_id, 'amount': dewies_to_lbc(amount) }]) return tx async def abandon_claim(self, claim_id, txid, nout, account): claim = await account.get_claim(claim_id=claim_id, txid=txid, nout=nout) if not claim: raise Exception('No claim found for the specified claim_id or txid:nout') tx = await Transaction.abandon(claim, [account], account) await account.ledger.broadcast(tx) # TODO: release reserved tx outputs in case anything fails by this point return tx async def claim_new_channel(self, channel_name, amount, account): address = await account.receiving.get_or_create_usable_address() cert, key = generate_certificate() tx = await Transaction.claim(channel_name, cert, amount, address, [account], account) await account.ledger.broadcast(tx) account.add_certificate_private_key(tx.outputs[0].ref, key.decode()) # TODO: release reserved tx outputs in case anything fails by this point await self.old_db.save_claims([self._old_get_temp_claim_info( tx, tx.outputs[0], address, cert, channel_name, dewies_to_lbc(amount) )]) return tx def _old_get_temp_claim_info(self, tx, txo, address, claim_dict, name, bid): return { "claim_id": txo.claim_id, "name": name, "amount": bid, "address": address, "txid": tx.id, "nout": txo.position, "value": claim_dict, "height": -1, "claim_sequence": -1, } def get_certificates(self, private_key_accounts, exclude_without_key=True, **constraints): return self.db.get_certificates( private_key_accounts=private_key_accounts, exclude_without_key=exclude_without_key, **constraints ) def update_peer_address(self, peer, address): pass # TODO: Data payments is disabled def get_unused_address_for_peer(self, peer): # TODO: Data payments is disabled return self.get_unused_address() def add_expected_payment(self, peer, amount): pass # TODO: Data payments is disabled def send_points(self, reserved_points, amount): pass # TODO: Data payments is disabled def cancel_point_reservation(self, reserved_points): pass # fixme: disabled for now. def save(self): for wallet in self.wallets: wallet.save() def get_block(self, block_hash=None, height=None): if height is None: height = self.ledger.headers.height if block_hash is None: block_hash = self.ledger.headers.hash(height).decode() return self.ledger.network.get_block(block_hash) def get_claim_by_claim_id(self, claim_id): return self.ledger.get_claim_by_claim_id(claim_id) def get_claim_by_outpoint(self, txid, nout): return self.ledger.get_claim_by_outpoint(txid, nout)