diff --git a/cli/mobilecoin/cli.py b/cli/mobilecoin/cli.py index 4fb6ef887..7904481a3 100644 --- a/cli/mobilecoin/cli.py +++ b/cli/mobilecoin/cli.py @@ -98,6 +98,8 @@ def _create_parsers(self): self.export_args.add_argument('account_id', help='ID of the account to export.') self.export_args.add_argument('-s', '--show', action='store_true', help='Only show the secret entropy mnemonic, do not write it to file.') + self.export_args.add_argument('-V', '--view', action='store_true', + help='Show the view-private-key only.') # Remove account. self.remove_args = command_sp.add_parser('remove', help='Remove an account from local storage.') @@ -168,10 +170,14 @@ def _create_parsers(self): def _load_account_prefix(self, prefix): accounts = self.client.get_all_accounts() + view_accounts = self.client.get_all_view_only_accounts() + accounts.update(view_accounts) + matching_ids = [ a_id for a_id in accounts.keys() if a_id.startswith(prefix) ] + if len(matching_ids) == 0: print('Could not find account starting with', prefix) exit(1) @@ -269,10 +275,11 @@ def status(self): )) print('Network fee is {}'.format(_format_mob(fee))) - def list(self, **args): - accounts = self.client.get_all_accounts(**args) + def list(self): + accounts = self.client.get_all_accounts() + view_accounts = self.client.get_all_view_only_accounts() - if len(accounts) == 0: + if len(accounts) + len(view_accounts) == 0: print('No accounts.') return @@ -281,7 +288,12 @@ def list(self, **args): balance = self.client.get_balance_for_account(account_id) account_list.append((account_id, account, balance)) - for (account_id, account, balance) in account_list: + view_account_list = [] + for account_id, view_account in view_accounts.items(): + balance = self.client.get_balance_for_view_only_account(account_id) + view_account_list.append((account_id, view_account, balance)) + + for (account_id, account, balance) in account_list + view_account_list: print() _print_account(account, balance) @@ -320,6 +332,8 @@ def import_(self, backup, name=None, block=None, key_derivation_version=2): account = self.client.import_account(**data) elif 'legacy_root_entropy' in data: account = self.client.import_account_from_legacy_root_entropy(**data) + elif 'view_private_key' in data: + account = self.client.import_view_only_account(**data) else: raise ValueError('Could not import account from {}'.format(backup)) @@ -328,7 +342,11 @@ def import_(self, backup, name=None, block=None, key_derivation_version=2): _print_account(account) print() - def export(self, account_id, show=False): + def export(self, account_id, show=False, view=False): + if view: + self._export_view_key(account_id, show) + return + account = self._load_account_prefix(account_id) account_id = account['account_id'] balance = self.client.get_balance_for_account(account_id) @@ -369,22 +387,76 @@ def export(self, account_id, show=False): else: print(f'Wrote {filename}.') - def remove(self, account_id): + def _export_view_key(self, account_id, show): account = self._load_account_prefix(account_id) account_id = account['account_id'] balance = self.client.get_balance_for_account(account_id) - print('You are about to remove this account:') + print('You are about to export the private view key for this account:') print() _print_account(account, balance) + print() - print('You will lose access to the funds in this account unless you') - print('restore it from the mnemonic phrase.') + if show: + print('The private view key will display on your screen.') + print('Make sure your screen is not being viewed or recorded.') + else: + print('Keep the view key file safe and private!') + print('Anyone who has access to the view key can see all transactions for the account.') + + if show: + confirm_message = 'Really show account view key? (Y/N) ' + else: + confirm_message = 'Really write private view key to a file? (Y/N) ' + + if not self.confirm(confirm_message): + print('Cancelled.') + return + + secrets = self.client.export_account_secrets(account_id) + if show: + print() + print(secrets['account_key']['view_private_key']) + print() + else: + filename = 'mobilecoin_view_key_{}.json'.format(account_id[:16]) + try: + _save_view_key_export(account, secrets, filename) + except OSError as e: + print('Could not write file: {}'.format(e)) + exit(1) + else: + print(f'Wrote {filename}.') + + def remove(self, account_id): + account = self._load_account_prefix(account_id) + account_id = account['account_id'] + + if account['object'] == 'view_only_account': + balance = self.client.get_balance_for_view_only_account(account_id) + print('You are about to remove this view key:') + print() + _print_account(account, balance) + print() + print('You will lose the ability to see related transactions unless you') + print('restore it from backup.') + else: + balance = self.client.get_balance_for_account(account_id) + print('You are about to remove this account:') + print() + _print_account(account, balance) + print() + print('You will lose access to the funds in this account unless you') + print('restore it from the mnemonic phrase.') + if not self.confirm('Continue? (Y/N) '): print('Cancelled.') return - self.client.remove_account(account_id) + if account['object'] == 'view_only_account': + self.client.remove_view_only_account(account_id) + else: + self.client.remove_account(account_id) print('Removed.') def history(self, account_id): @@ -735,7 +807,12 @@ def _format_decimal(d): def _format_account_header(account): - return '{} {}'.format(account['account_id'][:6], account['name']) + output = account['account_id'][:6] + if account['name']: + output += ' ' + account['name'] + if account.get('object') == 'view_only_account': + output += ' [view-only]' + return output def _format_balance(balance): @@ -745,11 +822,11 @@ def _format_balance(balance): offline = True network_block = int(balance['local_block_height']) - orphaned = pmob2mob(balance['orphaned_pmob']) - if orphaned > 0: - orphaned_status = ', {} orphaned'.format(_format_mob(orphaned)) - else: - orphaned_status = '' + orphaned_status = '' + if 'orphaned_pmob' in balance: + orphaned = pmob2mob(balance['orphaned_pmob']) + if orphaned > 0: + orphaned_status = ', {} orphaned'.format(_format_mob(orphaned)) account_block = int(balance['account_block_height']) if account_block == network_block: @@ -762,8 +839,13 @@ def _format_balance(balance): else: offline_status = '' + if 'unspent_pmob' in balance: + amount = balance['unspent_pmob'] + elif 'balance' in balance: + amount = balance['balance'] + result = '{}{} ({}){}'.format( - _format_mob(pmob2mob(balance['unspent_pmob'])), + _format_mob(pmob2mob(amount)), orphaned_status, sync_status, offline_status, @@ -781,10 +863,11 @@ def _format_gift_code_status(status): def _print_account(account, balance=None): print(_format_account_header(account)) - print(indent( - 'address {}'.format(account['main_address']), - ' '*2, - )) + if 'main_address' in account: + print(indent( + 'address {}'.format(account['main_address']), + ' '*2, + )) if balance is not None: print(indent( _format_balance(balance), @@ -856,20 +939,22 @@ def _load_import_file(filename): 'name', 'first_block_index', 'next_subaddress_index', + 'view_private_key', ]: value = data.get(field) if value is not None: result[field] = value - result['fog_keys'] = {} - for field in [ - 'fog_report_url', - 'fog_report_id', - 'fog_authority_spki', - ]: - value = data['account_key'].get(field) - if value is not None: - result['fog_keys'][field] = value + if 'account_key' in data: + result['fog_keys'] = {} + for field in [ + 'fog_report_url', + 'fog_report_id', + 'fog_authority_spki', + ]: + value = data['account_key'].get(field) + if value is not None: + result['fog_keys'][field] = value return result @@ -893,9 +978,24 @@ def _save_export(account, secrets, filename): 'next_subaddress_index': account['next_subaddress_index'], }) + _save_json_file(filename, export_data) + + +def _save_view_key_export(account, secrets, filename): + _save_json_file( + filename, + { + 'account_name': account['name'], + 'view_private_key': secrets['account_key']['view_private_key'], + 'first_block_index': account['first_block_index'], + } + ) + + +def _save_json_file(filename, data): path = Path(filename) if path.exists(): raise OSError('File exists.') with path.open('w') as f: - json.dump(export_data, f, indent=2) + json.dump(data, f, indent=2) f.write('\n') diff --git a/cli/mobilecoin/client.py b/cli/mobilecoin/client.py index 2146c1d74..f34d134b0 100755 --- a/cli/mobilecoin/client.py +++ b/cli/mobilecoin/client.py @@ -54,7 +54,6 @@ def _req(self, request_data): if self.verbose: print(r.status, http.client.responses[r.status]) - print(repr(raw_response)) print(len(raw_response), 'bytes') print(json.dumps(response_data, indent=2)) print() @@ -117,10 +116,29 @@ def import_account_from_legacy_root_entropy(self, legacy_root_entropy, name=None }) return r['account'] + def import_view_only_account(self, view_private_key, name=None, first_block_index=None): + params = { + "view_private_key": view_private_key, + } + if name is not None: + params['name'] = name + if first_block_index is not None: + params['first_block_index'] = str(int(first_block_index)) + + r = self._req({ + "method": "import_view_only_account", + "params": params + }) + return r['view_only_account'] + def get_all_accounts(self): r = self._req({"method": "get_all_accounts"}) return r['account_map'] + def get_all_view_only_accounts(self): + r = self._req({"method": "get_all_view_only_accounts"}) + return r['account_map'] + def get_account(self, account_id): r = self._req({ "method": "get_account", @@ -144,6 +162,12 @@ def remove_account(self, account_id): "params": {"account_id": account_id} }) + def remove_view_only_account(self, account_id): + return self._req({ + "method": "remove_view_only_account", + "params": {"account_id": account_id} + }) + def export_account_secrets(self, account_id): r = self._req({ "method": "export_account_secrets", @@ -186,6 +210,15 @@ def get_balance_for_account(self, account_id): }) return r['balance'] + def get_balance_for_view_only_account(self, account_id): + r = self._req({ + "method": "get_balance_for_view_only_account", + "params": { + "account_id": account_id, + } + }) + return r['balance'] + def get_balance_for_address(self, address): r = self._req({ "method": "get_balance_for_address",