class TestAccountUtils(unittest.TestCase): def setUp(self): self.keystore_dir = mkdtemp() self.account_utils = AccountUtils(self.keystore_dir) def tearDown(self): shutil.rmtree(self.keystore_dir, ignore_errors=True) def test_new_account(self): """ Simple account creation test case. 1) verifies the current account list is empty 2) creates a new account and verify we can retrieve it 3) tries to unlock the account """ # 1) verifies the current account list is empty account_list = self.account_utils.get_account_list() self.assertEqual(len(account_list), 0) # 2) creates a new account and verify we can retrieve it password = PASSWORD account = self.account_utils.new_account(password) account_list = self.account_utils.get_account_list() self.assertEqual(len(account_list), 1) self.assertEqual(account, self.account_utils.get_account_list()[0]) # 3) tries to unlock the account # it's unlocked by default after creation self.assertFalse(account.locked) # let's lock it and unlock it back account.lock() self.assertTrue(account.locked) account.unlock(password) self.assertFalse(account.locked) def test_deleted_account_dir(self): """ The deleted_account_dir() helper method should be working with and without trailing slash. """ expected_deleted_keystore_dir = '/tmp/keystore-deleted' keystore_dirs = [ # without trailing slash '/tmp/keystore', # with one trailing slash '/tmp/keystore/', # with two trailing slashes '/tmp/keystore//', ] for keystore_dir in keystore_dirs: self.assertEqual( AccountUtils.deleted_account_dir(keystore_dir), expected_deleted_keystore_dir) def test_delete_account(self): """ Creates a new account and delete it. Then verify we can load the account from the backup/trash location. """ password = PASSWORD account = self.account_utils.new_account(password) address = account.address self.assertEqual(len(self.account_utils.get_account_list()), 1) # deletes the account and verifies it's not loaded anymore self.account_utils.delete_account(account) self.assertEqual(len(self.account_utils.get_account_list()), 0) # even recreating the AccountUtils object self.account_utils = AccountUtils(self.keystore_dir) self.assertEqual(len(self.account_utils.get_account_list()), 0) # tries to reload it from the backup/trash location deleted_keystore_dir = AccountUtils.deleted_account_dir( self.keystore_dir) self.account_utils = AccountUtils(deleted_keystore_dir) self.assertEqual(len(self.account_utils.get_account_list()), 1) self.assertEqual( self.account_utils.get_account_list()[0].address, address) def test_delete_account_already_exists(self): """ If the destination (backup/trash) directory where the account is moved already exists, it should be handled gracefully. This could happens if the account gets deleted, then reimported and deleted again, refs: https://github.com/AndreMiras/PyWallet/issues/88 """ password = PASSWORD account = self.account_utils.new_account(password) # creates a file in the backup/trash folder that would conflict # with the deleted account deleted_keystore_dir = AccountUtils.deleted_account_dir( self.keystore_dir) os.makedirs(deleted_keystore_dir) account_filename = os.path.basename(account.path) deleted_account_path = os.path.join( deleted_keystore_dir, account_filename) # create that file open(deleted_account_path, 'a').close() # then deletes the account and verifies it worked self.assertEqual(len(self.account_utils.get_account_list()), 1) self.account_utils.delete_account(account) self.assertEqual(len(self.account_utils.get_account_list()), 0)
class TestAccountUtils(unittest.TestCase): def setUp(self): self.keystore_dir = mkdtemp() self.account_utils = AccountUtils(self.keystore_dir) def tearDown(self): shutil.rmtree(self.keystore_dir, ignore_errors=True) def test_new_account(self): """ Simple account creation test case. 1) verifies the current account list is empty 2) creates a new account and verify we can retrieve it 3) tries to unlock the account """ # 1) verifies the current account list is empty self.assertEqual(self.account_utils.get_account_list(), []) # 2) creates a new account and verify we can retrieve it password = PASSWORD account = self.account_utils.new_account(password, iterations=1) self.assertEqual(len(self.account_utils.get_account_list()), 1) self.assertEqual(account, self.account_utils.get_account_list()[0]) # 3) tries to unlock the account # it's unlocked by default after creation self.assertFalse(account.locked) # let's lock it and unlock it back account.lock() self.assertTrue(account.locked) account.unlock(password) self.assertFalse(account.locked) def test_get_account_list(self): """ Makes sure get_account_list() loads properly accounts from file system. """ password = PASSWORD self.assertEqual(self.account_utils.get_account_list(), []) account = self.account_utils.new_account(password, iterations=1) self.assertEqual(len(self.account_utils.get_account_list()), 1) account = self.account_utils.get_account_list()[0] self.assertIsNotNone(account.path) # removes the cache copy and checks again if it gets loaded self.account_utils._accounts = None self.assertEqual(len(self.account_utils.get_account_list()), 1) account = self.account_utils.get_account_list()[0] self.assertIsNotNone(account.path) def test_get_account_list_error(self): """ get_account_list() should not cache empty account on PermissionError. """ # creates a temporary account that we'll try to list password = PASSWORD account = Account.new(password, uuid=None, iterations=1) account.path = os.path.join(self.keystore_dir, account.address.hex()) with open(account.path, 'w') as f: f.write(account.dump()) # `listdir()` can raise a `PermissionError` with mock.patch('os.listdir') as mock_listdir: mock_listdir.side_effect = PermissionError with self.assertRaises(PermissionError): self.account_utils.get_account_list() # the empty account list should not be catched and loading it again # should show the existing account on file system self.assertEqual(len(self.account_utils.get_account_list()), 1) self.assertEqual(self.account_utils.get_account_list()[0].address, account.address) def test_get_account_list_no_dir(self): """ The keystore directory should be created if it doesn't exist, refs: https://github.com/AndreMiras/PyWallet/issues/133 """ # nominal case when the directory already exists with TemporaryDirectory() as keystore_dir: self.assertTrue(os.path.isdir(keystore_dir)) account_utils = AccountUtils(keystore_dir) self.assertEqual(account_utils.get_account_list(), []) # when the directory doesn't exist it should also be created self.assertFalse(os.path.isdir(keystore_dir)) account_utils = AccountUtils(keystore_dir) self.assertTrue(os.path.isdir(keystore_dir)) self.assertEqual(account_utils.get_account_list(), []) shutil.rmtree(keystore_dir, ignore_errors=True) def test_deleted_account_dir(self): """ The deleted_account_dir() helper method should be working with and without trailing slash. """ expected_deleted_keystore_dir = '/tmp/keystore-deleted' keystore_dirs = [ # without trailing slash '/tmp/keystore', # with one trailing slash '/tmp/keystore/', # with two trailing slashes '/tmp/keystore//', ] for keystore_dir in keystore_dirs: self.assertEqual(AccountUtils.deleted_account_dir(keystore_dir), expected_deleted_keystore_dir) def test_delete_account(self): """ Creates a new account and delete it. Then verify we can load the account from the backup/trash location. """ password = PASSWORD account = self.account_utils.new_account(password, iterations=1) address = account.address self.assertEqual(len(self.account_utils.get_account_list()), 1) # deletes the account and verifies it's not loaded anymore self.account_utils.delete_account(account) self.assertEqual(len(self.account_utils.get_account_list()), 0) # even recreating the AccountUtils object self.account_utils = AccountUtils(self.keystore_dir) self.assertEqual(len(self.account_utils.get_account_list()), 0) # tries to reload it from the backup/trash location deleted_keystore_dir = AccountUtils.deleted_account_dir( self.keystore_dir) self.account_utils = AccountUtils(deleted_keystore_dir) self.assertEqual(len(self.account_utils.get_account_list()), 1) self.assertEqual(self.account_utils.get_account_list()[0].address, address) def test_delete_account_already_exists(self): """ If the destination (backup/trash) directory where the account is moved already exists, it should be handled gracefully. This could happens if the account gets deleted, then reimported and deleted again, refs: https://github.com/AndreMiras/PyWallet/issues/88 """ password = PASSWORD account = self.account_utils.new_account(password, iterations=1) # creates a file in the backup/trash folder that would conflict # with the deleted account deleted_keystore_dir = AccountUtils.deleted_account_dir( self.keystore_dir) os.makedirs(deleted_keystore_dir) account_filename = os.path.basename(account.path) deleted_account_path = os.path.join(deleted_keystore_dir, account_filename) # create that file open(deleted_account_path, 'a').close() # then deletes the account and verifies it worked self.assertEqual(len(self.account_utils.get_account_list()), 1) self.account_utils.delete_account(account) self.assertEqual(len(self.account_utils.get_account_list()), 0)
class PyWalib: def __init__(self, keystore_dir=None, chain_id=ChainID.MAINNET): if keystore_dir is None: keystore_dir = PyWalib.get_default_keystore_path() self.keystore_dir = keystore_dir self.account_utils = AccountUtils(keystore_dir=self.keystore_dir) self.chain_id = chain_id self.provider = HTTPProviderFactory.create(self.chain_id) self.web3 = Web3(self.provider) @staticmethod def handle_etherscan_error(response_json): """ Raises an exception on unexpected response. """ status = response_json["status"] message = response_json["message"] if status != "1": if message == "No transactions found": raise NoTransactionFoundException() else: raise UnknownEtherscanException(response_json) assert message == "OK" @staticmethod def get_balance(address, chain_id=ChainID.MAINNET): """ Retrieves the balance from etherscan.io. The balance is returned in ETH rounded to the second decimal. """ address = to_checksum_address(address) url = get_etherscan_prefix(chain_id) url += '?module=account&action=balance' url += '&address=%s' % address url += '&tag=latest' if ETHERSCAN_API_KEY: '&apikey=%' % ETHERSCAN_API_KEY # TODO: handle 504 timeout, 403 and other errors from etherscan response = requests.get(url) response_json = response.json() PyWalib.handle_etherscan_error(response_json) balance_wei = int(response_json["result"]) balance_eth = balance_wei / float(pow(10, 18)) balance_eth = round(balance_eth, ROUND_DIGITS) return balance_eth def get_balance_web3(self, address): """ The balance is returned in ETH rounded to the second decimal. """ address = to_checksum_address(address) balance_wei = self.web3.eth.getBalance(address) balance_eth = balance_wei / float(pow(10, 18)) balance_eth = round(balance_eth, ROUND_DIGITS) return balance_eth @staticmethod def get_transaction_history(address, chain_id=ChainID.MAINNET): """ Retrieves the transaction history from etherscan.io. """ address = to_checksum_address(address) url = get_etherscan_prefix(chain_id) url += '?module=account&action=txlist' url += '&sort=asc' url += '&address=%s' % address if ETHERSCAN_API_KEY: '&apikey=%' % ETHERSCAN_API_KEY # TODO: handle 504 timeout, 403 and other errors from etherscan response = requests.get(url) response_json = response.json() PyWalib.handle_etherscan_error(response_json) transactions = response_json['result'] for transaction in transactions: value_wei = int(transaction['value']) value_eth = value_wei / float(pow(10, 18)) value_eth = round(value_eth, ROUND_DIGITS) from_address = to_checksum_address(transaction['from']) to_address = transaction['to'] # on contract creation, "to" is replaced by the "contractAddress" if not to_address: to_address = transaction['contractAddress'] to_address = to_checksum_address(to_address) sent = from_address == address received = not sent extra_dict = { 'value_eth': value_eth, 'sent': sent, 'received': received, 'from_address': from_address, 'to_address': to_address, } transaction.update({'extra_dict': extra_dict}) # sort by timeStamp transactions.sort(key=lambda x: x['timeStamp']) return transactions @staticmethod def get_out_transaction_history(address, chain_id=ChainID.MAINNET): """ Retrieves the outbound transaction history from Etherscan. """ transactions = PyWalib.get_transaction_history(address, chain_id) out_transactions = [] for transaction in transactions: if transaction['extra_dict']['sent']: out_transactions.append(transaction) return out_transactions # TODO: can be removed since the migration to web3 @staticmethod def get_nonce(address, chain_id=ChainID.MAINNET): """ Gets the nonce by counting the list of outbound transactions from Etherscan. """ try: out_transactions = PyWalib.get_out_transaction_history( address, chain_id) except NoTransactionFoundException: out_transactions = [] nonce = len(out_transactions) return nonce @staticmethod def handle_web3_exception(exception: ValueError): """ Raises the appropriated typed exception on web3 ValueError exception. """ error = exception.args[0] code = error.get("code") if code in [-32000, -32010]: raise InsufficientFundsException(error) else: raise UnknownEtherscanException(error) def transact(self, to, value=0, data='', sender=None, gas=25000, gasprice=DEFAULT_GAS_PRICE_GWEI * (10**9)): """ Signs and broadcasts a transaction. Returns transaction hash. """ address = sender or self.get_main_account().address from_address_normalized = to_checksum_address(address) nonce = self.web3.eth.getTransactionCount(from_address_normalized) transaction = { 'chainId': self.chain_id.value, 'gas': gas, 'gasPrice': gasprice, 'nonce': nonce, 'value': value, } account = self.account_utils.get_by_address(address) private_key = account.privkey signed_tx = self.web3.eth.account.signTransaction( transaction, private_key) try: tx_hash = self.web3.eth.sendRawTransaction( signed_tx.rawTransaction) except ValueError as e: self.handle_web3_exception(e) return tx_hash @staticmethod def deleted_account_dir(keystore_dir): """ Given a `keystore_dir`, returns the corresponding `deleted_keystore_dir`. >>> keystore_dir = '/tmp/keystore' >>> PyWalib.deleted_account_dir(keystore_dir) u'/tmp/keystore-deleted' >>> keystore_dir = '/tmp/keystore/' >>> PyWalib.deleted_account_dir(keystore_dir) u'/tmp/keystore-deleted' """ keystore_dir = keystore_dir.rstrip('/') keystore_dir_name = os.path.basename(keystore_dir) deleted_keystore_dir_name = "%s-deleted" % (keystore_dir_name) deleted_keystore_dir = os.path.join(os.path.dirname(keystore_dir), deleted_keystore_dir_name) return deleted_keystore_dir # TODO: update docstring # TODO: update security_ratio def new_account(self, password, security_ratio=None): """ Creates an account on the disk and returns it. security_ratio is a ratio of the default PBKDF2 iterations. Ranging from 1 to 100 means 100% of the iterations. """ account = self.account_utils.new_account(password=password) return account def delete_account(self, account): """ Deletes the given `account` from the `keystore_dir` directory. In fact, moves it to another location; another directory at the same level. """ self.account_utils.delete_account(account) def update_account_password(self, account, new_password, current_password=None): """ The current_password is optional if the account is already unlocked. """ self.account_utils.update_account_password(account, new_password, current_password) @staticmethod def get_default_keystore_path(): """ Returns the keystore path, which is the same as the default pyethapp one. """ keystore_dir = os.path.join(KEYSTORE_DIR_PREFIX, KEYSTORE_DIR_SUFFIX) return keystore_dir def get_account_list(self): """ Returns the Account list. """ accounts = self.account_utils.get_account_list() return accounts def get_main_account(self): """ Returns the main Account. """ account = self.get_account_list()[0] return account