lbry-sdk/lbrynet/wallet/manager.py

551 lines
21 KiB
Python

import os
import json
import logging
from binascii import unhexlify
from datetime import datetime
from typing import Optional
from twisted.internet import defer
from lbryschema.schema import SECP256k1
from torba.basemanager import BaseWalletManager
from lbryschema.claim import ClaimDict
from .ledger import MainNetLedger
from .account import BaseAccount, generate_certificate
from .transaction import Transaction
from .database import WalletDatabase
from .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 defer.succeed(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()
return account.encrypted and 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': 2},
'change': {'gap': 6, 'maximum_uses_per_address': 2}
}
}]
}, 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 = "%s.tmp.%s" % (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
@defer.inlineCallbacks
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['lbryum_wallet_dir'],
'use_keyring': settings['use_keyring'],
#'db': db
}
wallets_directory = os.path.join(settings['lbryum_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]
})
if manager.default_account is None:
ledger = manager.get_or_create_ledger('lbc_mainnet')
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:
ledger = manager.get_or_create_ledger(ledger_id)
if not os.path.exists(ledger.path):
os.mkdir(ledger.path)
yield ledger.db.open()
try:
yield manager._migrate_addresses(receiving_addresses, change_addresses)
finally:
yield ledger.db.close()
defer.returnValue(manager)
@defer.inlineCallbacks
def _migrate_addresses(self, receiving_addresses: set, change_addresses: set):
migrated_receiving = set((yield self.default_account.receiving.generate_keys(0, len(receiving_addresses))))
migrated_change = set((yield 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)
@defer.inlineCallbacks
def send_amount_to_address(self, amount: int, destination_address: bytes, account=None):
account = account or self.default_account
tx = yield Transaction.pay(amount, destination_address, [account], account)
yield account.ledger.broadcast(tx)
return tx
@defer.inlineCallbacks
def send_claim_to_address(self, claim_id: str, destination_address: str, amount: Optional[int],
account=None):
account = account or self.default_account
claims = account.ledger.db.get_utxos(claim_id=claim_id)
if not claims:
raise NameError("Claim not found: {}".format(claim_id))
tx = yield Transaction.update(
claims[0], ClaimDict.deserialize(claims[0].script.value['claim']), amount,
destination_address.encode(), [account], account
)
yield 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)
def get_wallet_info_query_handler_factory(self):
return LBRYcrdAddressQueryHandlerFactory(self)
def get_info_exchanger(self):
return LBRYcrdAddressRequester(self)
@defer.inlineCallbacks
def resolve(self, *uris, **kwargs):
page = kwargs.get('page', 0)
page_size = kwargs.get('page_size', 10)
check_cache = kwargs.get('check_cache', False) # TODO: put caching back (was force_refresh parameter)
ledger = self.default_account.ledger # type: MainNetLedger
results = yield ledger.resolve(page, page_size, *uris)
yield self.old_db.save_claims_for_resolve(
(value for value in results.values() if 'error' not in value))
defer.returnValue(results)
def get_claims_for_name(self, name: str):
return self.ledger.network.get_claims_for_name(name)
@defer.inlineCallbacks
def address_is_mine(self, unknown_address, account):
match = yield self.ledger.db.get_address(address=unknown_address, account=account)
if match is not None:
return True
return False
def get_transaction(self, txid: str):
return self.default_account.ledger.get_transaction(txid)
@staticmethod
@defer.inlineCallbacks
def get_history(account: BaseAccount, **constraints):
headers = account.ledger.headers
txs = (yield account.get_transactions(**constraints))
history = []
for tx in txs:
ts = headers[tx.height]['timestamp']
item = {
'txid': tx.id,
'timestamp': ts,
'value': dewies_to_lbc(tx.net_account_balance),
'fee': dewies_to_lbc(tx.fee),
'date': datetime.fromtimestamp(ts).isoformat(' ')[:-3],
'confirmations': headers.height - tx.height,
'claim_info': [{
'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_claim_outputs],
'update_info': [{
'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],
'support_info': [{
'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': not txo.is_my_account,
'nout': txo.position
} for txo in tx.my_support_outputs],
'abandon_info': [{
'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_abandon_outputs],
}
if all([txi.txo_ref.txo is not None for txi in tx.inputs]):
item['fee'] = dewies_to_lbc(tx.fee)
else:
item['fee'] = '0' # can't calculate fee without all input txos
history.append(item)
return history
@staticmethod
def get_utxos(account: BaseAccount):
return account.get_utxos()
@defer.inlineCallbacks
def claim_name(self, name, amount, claim_dict, certificate=None, claim_address=None):
account = self.default_account
claim = ClaimDict.load_dict(claim_dict)
if not claim_address:
claim_address = yield account.receiving.get_or_create_usable_address()
if certificate:
claim = claim.sign(
certificate.signature, claim_address, certificate.claim_id, curve=SECP256k1
)
existing_claims = yield account.get_claims(claim_name=name)
if len(existing_claims) == 0:
tx = yield Transaction.claim(
name, claim, amount, claim_address, [account], account
)
elif len(existing_claims) == 1:
tx = yield Transaction.update(
existing_claims[0], claim, amount, claim_address, [account], account
)
else:
raise NameError("More than one other claim exists with the name '{}'.".format(name))
yield account.ledger.broadcast(tx)
yield self.old_db.save_claims([self._old_get_temp_claim_info(
tx, tx.outputs[0], claim_address, claim_dict, name, amount
)])
# TODO: release reserved tx outputs in case anything fails by this point
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,
}
@defer.inlineCallbacks
def support_claim(self, claim_name, claim_id, amount, account):
holding_address = yield account.receiving.get_or_create_usable_address()
tx = yield Transaction.support(claim_name, claim_id, amount, holding_address, [account], account)
yield account.ledger.broadcast(tx)
return tx
@defer.inlineCallbacks
def tip_claim(self, amount, claim_id, account):
claim_to_tip = yield self.get_claim_by_claim_id(claim_id)
tx = yield Transaction.support(
claim_to_tip['name'], claim_id, amount, claim_to_tip['address'], [account], account
)
yield account.ledger.broadcast(tx)
return tx
@defer.inlineCallbacks
def abandon_claim(self, claim_id, txid, nout, account):
claim = yield 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 = yield Transaction.abandon(claim, [account], account)
yield account.ledger.broadcast(tx)
# TODO: release reserved tx outputs in case anything fails by this point
defer.returnValue(tx)
@defer.inlineCallbacks
def claim_new_channel(self, channel_name, amount):
account = self.default_account
address = yield account.receiving.get_or_create_usable_address()
cert, key = generate_certificate()
tx = yield Transaction.claim(channel_name, cert, amount, address, [account], account)
yield 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
defer.returnValue(tx)
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):
defer.succeed(True) # 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)
class ClientRequest:
def __init__(self, request_dict, response_identifier=None):
self.request_dict = request_dict
self.response_identifier = response_identifier
class LBRYcrdAddressRequester:
def __init__(self, wallet):
self.wallet = wallet
self._protocols = []
def send_next_request(self, peer, protocol):
if not protocol in self._protocols:
r = ClientRequest({'lbrycrd_address': True}, 'lbrycrd_address')
d = protocol.add_request(r)
d.addCallback(self._handle_address_response, peer, r, protocol)
d.addErrback(self._request_failed, peer)
self._protocols.append(protocol)
return defer.succeed(True)
else:
return defer.succeed(False)
def _handle_address_response(self, response_dict, peer, request, protocol):
if request.response_identifier not in response_dict:
raise ValueError(
"Expected {} in response but did not get it".format(request.response_identifier))
assert protocol in self._protocols, "Responding protocol is not in our list of protocols"
address = response_dict[request.response_identifier]
self.wallet.update_peer_address(peer, address)
def _request_failed(self, error, peer):
raise Exception(
"A peer failed to send a valid public key response. Error: {}, peer: {}".format(
error.getErrorMessage(), str(peer)
)
)
class LBRYcrdAddressQueryHandlerFactory:
def __init__(self, wallet):
self.wallet = wallet
def build_query_handler(self):
q_h = LBRYcrdAddressQueryHandler(self.wallet)
return q_h
def get_primary_query_identifier(self):
return 'lbrycrd_address'
def get_description(self):
return "LBRYcrd Address - an address for receiving payments via LBRYcrd"
class LBRYcrdAddressQueryHandler:
def __init__(self, wallet):
self.wallet = wallet
self.query_identifiers = ['lbrycrd_address']
self.address = None
self.peer = None
def register_with_request_handler(self, request_handler, peer):
self.peer = peer
request_handler.register_query_handler(self, self.query_identifiers)
def handle_queries(self, queries):
def create_response(address):
self.address = address
fields = {'lbrycrd_address': address}
return fields
if self.query_identifiers[0] in queries:
d = self.wallet.get_unused_address_for_peer(self.peer)
d.addCallback(create_response)
return d
if self.address is None:
raise Exception("Expected a request for an address, but did not receive one")
else:
return defer.succeed({})