import os import json import logging from binascii import unhexlify from datetime import datetime from typing import Optional from torba.client.basemanager import BaseWalletManager from torba.rpc.jsonrpc import CodeMessageError from lbrynet.schema.claim import Claim from lbrynet.wallet.ledger import MainNetLedger from lbrynet.wallet.account import BaseAccount from lbrynet.wallet.transaction import Transaction, Output, Input from lbrynet.wallet.database import WalletDatabase from lbrynet.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], claims[0].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: Claim, certificate=None, claim_address=None): claim_address = claim_address or await account.receiving.get_or_create_usable_address() existing_claims = await account.get_claims( claim_name_type__any={'is_claim': 1, 'is_update': 1}, # exclude is_supports claim_name=name ) inputs = [] if len(existing_claims) == 0: claim_output = Output.pay_claim_name_pubkey_hash( amount, name, claim, account.ledger.address_to_hash160(claim_address) ) elif len(existing_claims) == 1: previous_claim = existing_claims[0] claim_output = Output.pay_update_claim_pubkey_hash( amount, previous_claim.claim_name, previous_claim.claim_id, claim, account.ledger.address_to_hash160(claim_address) ) inputs = [Input.spend(previous_claim)] else: raise NameError(f"More than one other claim exists with the name '{name}'.") if certificate: claim_output.sign(certificate, first_input_id=b'placeholder') tx = await Transaction.create(inputs, [claim_output], [account], account) if certificate: claim_output.sign(certificate) await tx.sign([account]) await account.ledger.broadcast(tx) await self.old_db.save_claims([self._old_get_temp_claim_info( tx, tx.outputs[0], claim_address, claim, 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() claim = Claim() claim_output = Output.pay_claim_name_pubkey_hash( amount, channel_name, claim, account.ledger.address_to_hash160(address) ) key = claim_output.generate_channel_private_key() claim_output.script.generate() tx = await Transaction.create([], [claim_output], [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, claim, 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)