diff --git a/python/OLD_README.md b/python/OLD_README.md new file mode 100644 index 000000000..d10b1a69f --- /dev/null +++ b/python/OLD_README.md @@ -0,0 +1,65 @@ +# MobileCoin command-line interface +Command line interface and client library for MobileCoin full-service node. + + +## Install Python3 and Pip + +Ubuntu: +```shell +sudo apt install python3-pip +``` + +Mac: +```shell +brew install python3 +``` + +## Install mobcli + +```shell +pip3 install . +``` + +Check that it is installed. +```shell +mobcli -h +``` + +## Configure mobcli + +Copy the config file to your home directory. +```shell +cp mc_env.sh ~/.mc_env.sh +``` + +Add the following lines to your .bashrc: +```shell +if [ -f "$HOME/.mc_env.sh" ]; then + source "$HOME/.mc_env.sh" +fi +``` + +The CLI sends its requests to the wallet service executable. Download it from https://github.com/mobilecoinofficial/full-service/releases. Copy the file to the correct location. + +```shell +cp full-service ~/.mobilecoin/testnet/full-service-testnet +``` + +The environment variables file specifies to connect to the test network by default, but +you can change it to connect to the main network if you know what you're doing, and are +confident you will not lose actual funds. + + +## Start the server + +```shell +mobcli start +``` + +## Including the client library in packages + +In order to reference the full-service Python client library for package dependencies, it is necessary to install via git, because it is not listed on PyPI. The pip install line for it is: + +``` +git+https://github.com/mobilecoinofficial/full-service.git#subdirectory=cli +``` diff --git a/python/README.md b/python/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/python/bin/mob b/python/bin/mob new file mode 100755 index 000000000..5f89a2156 --- /dev/null +++ b/python/bin/mob @@ -0,0 +1,3 @@ +#! /usr/bin/env python3 +from mobilecoin import CommandLineInterface +CommandLineInterface().main() diff --git a/python/mobilecoin/__init__.py b/python/mobilecoin/__init__.py new file mode 100644 index 000000000..79f98a37b --- /dev/null +++ b/python/mobilecoin/__init__.py @@ -0,0 +1,7 @@ +from mobilecoin.cli import CommandLineInterface +from mobilecoin.client import ( + Client, + WalletAPIError, + mob2pmob, + pmob2mob, +) diff --git a/python/mobilecoin/cli.py b/python/mobilecoin/cli.py new file mode 100644 index 000000000..dfe44330f --- /dev/null +++ b/python/mobilecoin/cli.py @@ -0,0 +1,936 @@ +import argparse +from decimal import Decimal +import json +from pathlib import Path +from textwrap import indent + +from .client import ( + Client, WalletAPIError, + MAX_TOMBSTONE_BLOCKS, + pmob2mob, +) + + +class CommandLineInterface: + + def __init__(self): + self.verbose = False + + def main(self): + self._create_parsers() + + args = self.parser.parse_args() + args = vars(args) + command = args.pop('command') + if command is None: + self.parser.print_help() + exit(1) + + self.verbose = args.pop('verbose') + self.auto_confirm = args.pop('yes') + + self.client = Client(verbose=self.verbose) + + # Dispatch command. + setattr(self, 'import', self.import_) # Can't name a function "import". + command = command.translate(str.maketrans('-', '_')) + command_func = getattr(self, command) + try: + command_func(**args) + except ConnectionError as e: + print(e) + exit(1) + + def _create_parsers(self): + self.parser = argparse.ArgumentParser( + prog='mob', + description='MobileCoin command-line wallet.', + ) + self.parser.add_argument('-v', '--verbose', action='store_true', help='Show more information.') + self.parser.add_argument('-y', '--yes', action='store_true', help='Do not ask for confirmation.') + + command_sp = self.parser.add_subparsers(dest='command', help='Commands') + + # Start server. + self.start_args = command_sp.add_parser('start', help='Start the local MobileCoin wallet server.') + self.start_args.add_argument('--offline', action='store_true', help='Start in offline mode.') + self.start_args.add_argument('--bg', action='store_true', + help='Start server in the background, stop with "mobilecoin stop".') + self.start_args.add_argument('--unencrypted', action='store_true', + help='Do not encrypt the wallet database. Secret keys will be stored on the hard drive in plaintext.') + self.start_args.add_argument('--change-password', action='store_true', + help='Change the password for the database.') + + # Stop server. + self.stop_args = command_sp.add_parser('stop', help='Stop the local MobileCoin wallet server.') + + # Network status. + self.status_args = command_sp.add_parser('status', help='Check the status of the MobileCoin network.') + + # List accounts. + self.list_args = command_sp.add_parser('list', help='List accounts.') + + # Create account. + self.create_args = command_sp.add_parser('create', help='Create a new account.') + self.create_args.add_argument('-n', '--name', help='Account name.') + + # Rename account. + self.rename_args = command_sp.add_parser('rename', help='Change account name.') + self.rename_args.add_argument('account_id', help='ID of the account to rename.') + self.rename_args.add_argument('name', help='New account name.') + + # Import account. + self.import_args = command_sp.add_parser('import', help='Import an account.') + self.import_args.add_argument('backup', help='Account backup file, mnemonic recovery phrase, or legacy root entropy in hexadecimal.') + self.import_args.add_argument('-n', '--name', help='Account name.') + self.import_args.add_argument('-b', '--block', type=int, + help='Block index at which to start the account. No transactions before this block will be loaded.') + self.import_args.add_argument('--key_derivation_version', type=int, default=2, + help='The version number of the key derivation path which the mnemonic was created with.') + + # Export account. + self.export_args = command_sp.add_parser('export', help='Export secret entropy mnemonic.') + 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.') + + # Remove account. + self.remove_args = command_sp.add_parser('remove', help='Remove an account from local storage.') + self.remove_args.add_argument('account_id', help='ID of the account to remove.') + # Show transaction history. + self.history_args = command_sp.add_parser('history', help='Show account transaction history.') + self.history_args.add_argument('account_id', help='Account ID.') + + # Send transaction. + self.send_args = command_sp.add_parser('send', help='Send a transaction.') + self.send_args.add_argument('--build-only', action='store_true', help='Just build the transaction, do not submit it.') + self.send_args.add_argument('--fee', type=str, default=None, help='The fee paid to the network.') + self.send_args.add_argument('account_id', help='Source account ID.') + self.send_args.add_argument('amount', help='Amount of MOB to send.') + self.send_args.add_argument('to_address', help='Address to send to.') + + # Submit transaction proposal. + self.submit_args = command_sp.add_parser('submit', help='Submit a transaction proposal.') + self.submit_args.add_argument('proposal', help='A tx_proposal.json file.') + self.submit_args.add_argument('account_id', nargs='?', help='Source account ID. Only used for logging the transaction.') + self.submit_args.add_argument('--receipt', action='store_true', help='Also create a receiver receipt for the transaction.') + + # Address QR code. + self.qr_args = command_sp.add_parser('qr', help='Show account address as a QR code') + self.qr_args.add_argument('account_id', help='Account ID.') + + # Address commands. + self.address_args = command_sp.add_parser('address', help='Account receiving address commands.') + address_action = self.address_args.add_subparsers(dest='action') + + # List addresses. + self.address_list_args = address_action.add_parser('list', help='List addresses and balances for an account.') + self.address_list_args.add_argument('account_id', help='Account ID.') + + # Create address. + self.address_create_args = address_action.add_parser( + 'create', + help='Create a new receiving address for the specified account.', + ) + self.address_create_args.add_argument('account_id', help='Account ID.') + self.address_create_args.add_argument('metadata', nargs='?', help='Address label.') + + # Gift code commands. + self.gift_args = command_sp.add_parser('gift', help='Gift code commands.') + gift_action = self.gift_args.add_subparsers(dest='action') + + # List gift codes. + self.gift_list_args = gift_action.add_parser('list', help='List gift codes and their amounts.') + + # Create gift code. + self.gift_create_args = gift_action.add_parser('create', help='Create a new gift code.') + self.gift_create_args.add_argument('account_id', help='Source account ID.') + self.gift_create_args.add_argument('amount', help='Amount of MOB to add to the gift code.') + self.gift_create_args.add_argument('-m', '--memo', help='Gift code memo.') + + # Claim gift code. + self.gift_claim_args = gift_action.add_parser('claim', help='Claim a gift code, adding the funds to your account.') + self.gift_claim_args.add_argument('account_id', help='Destination account ID to deposit the gift code funds.') + self.gift_claim_args.add_argument('gift_code', help='Gift code string') + + # Remove gift code. + self.gift_remove_args = gift_action.add_parser('remove', help='Remove a gift code.') + self.gift_remove_args.add_argument('gift_code', help='Gift code to remove.') + + # Sync view-only account. + self.sync_args = command_sp.add_parser('sync', help='Sync a view-only account.') + self.sync_args.add_argument( + 'account_id_or_sync_response', + help=( + 'If an account ID is passed, then generate a sync request for the transaction signer. ' + 'Once the signer is finished, call this again with the completed json file.' + ) + ) + + # Version + self.version_args = command_sp.add_parser('version', help='Show version number.') + + def _load_account_prefix(self, prefix): + accounts = self.client.get_all_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) + elif len(matching_ids) == 1: + account_id = matching_ids[0] + return accounts[account_id] + else: + print('Multiple matching matching ids: {}'.format(', '.join(matching_ids))) + exit(1) + + def confirm(self, message): + if self.auto_confirm: + return True + confirmation = input(message) + return confirmation.lower() in ['y', 'yes'] + + def status(self): + network_status = self.client.get_network_status() + fee = pmob2mob(network_status['fee_pmob']) + + if int(network_status['network_block_height']) == 0: + print('Offline.') + print('Local ledger has {} blocks.'.format(network_status['local_block_height'])) + print('Expected fee is {}'.format(_format_mob(fee))) + else: + print('Connected to network.') + print('Local ledger has {}/{} blocks.'.format( + network_status['local_block_height'], + network_status['network_block_height'], + )) + print('Network fee is {}'.format(_format_mob(fee))) + + def list(self): + accounts = self.client.get_all_accounts() + if len(accounts) == 0: + print('No accounts.') + return + + account_list = [] + for account_id, account in accounts.items(): + balance = self.client.get_balance_for_account(account_id) + account_list.append((account_id, account, balance)) + + for (account_id, account, balance) in account_list: + print() + _print_account(account, balance) + + print() + + def create(self, **args): + account = self.client.create_account(**args) + print('Created a new account.') + print() + _print_account(account) + print() + + def rename(self, account_id, name): + account = self._load_account_prefix(account_id) + old_name = account['name'] + account_id = account['account_id'] + account = self.client.update_account_name(account_id, name) + print('Renamed account from "{}" to "{}".'.format( + old_name, + account['name'], + )) + print() + _print_account(account) + print() + + def import_(self, backup, name=None, block=None, key_derivation_version=2): + account = None + if backup.endswith('.json'): + with open(backup) as f: + data = json.load(f) + + if data.get('method') == 'import_view_only_account': + account = self.client.import_view_only_account(data['params']) + else: + params = {} + + for field in [ + 'mnemonic', # Key derivation version 2+. + 'entropy', # Key derivation version 1. + 'name', + 'first_block_index', + 'next_subaddress_index', + ]: + value = data.get(field) + if value is not None: + params[field] = value + + if 'account_key' in data: + params['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: + params['fog_keys'][field] = value + + if name is not None: + params['name'] = name + + account = self.client.import_account(**params) + + else: + # Try to use the legacy import system, treating the string as hexadecimal root entropy. + root_entropy = None + try: + b = bytes.fromhex(backup) + except ValueError: + pass + if len(b) == 32: + root_entropy = b.hex() + if root_entropy is not None: + account = self.client.import_account_from_legacy_root_entropy(root_entropy) + else: + # Lastly, assume that this is just a mnemonic phrase written to the command line. + account = self.client.import_account(backup) + + if account is None: + print('Could not import account.') + return + + print('Imported account.') + print() + _print_account(account) + print() + + def export(self, account_id, show=False): + 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 export the secret entropy mnemonic for this account:') + print() + _print_account(account, balance) + + print() + if show: + print('The entropy will display on your screen. Make sure your screen is not being viewed or recorded.') + else: + print('Keep the exported entropy file safe and private!') + print('Anyone who has access to the entropy can spend all the funds in the account.') + + if show: + confirm_message = 'Really show account entropy mnemonic? (Y/N) ' + else: + confirm_message = 'Really write account entropy mnemonic to a file? (Y/N) ' + if not self.confirm(confirm_message): + print('Cancelled.') + return + + secrets = self.client.export_account_secrets(account_id) + if show: + mnemonic_words = secrets['mnemonic'].upper().split() + print() + for i, word in enumerate(mnemonic_words, 1): + print('{:<2} {}'.format(i, word)) + print() + else: + filename = 'mobilecoin_secret_mnemonic_{}.json'.format(account_id[:6]) + try: + print(secrets) + _save_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'] + balance = self.client.get_balance_for_account(account_id) + + if account['view_only']: + 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: + 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) + print('Removed.') + + def history(self, account_id): + account = self._load_account_prefix(account_id) + account_id = account['account_id'] + + transactions = self.client.get_transaction_logs_for_account(account_id, limit=1000) + + def block_key(t): + submitted = t['submitted_block_index'] + finalized = t['finalized_block_index'] + if submitted is not None and finalized is not None: + return min([submitted, finalized]) + elif submitted is not None and finalized is None: + return submitted + elif submitted is None and finalized is not None: + return finalized + else: + return None + + transactions = sorted(transactions.values(), key=block_key) + + for t in transactions: + print() + if t['direction'] == 'tx_direction_received': + amount = _format_mob( + sum( + pmob2mob(txo['value_pmob']) + for txo in t['output_txos'] + ) + ) + print('Received {}'.format(amount)) + print(' at {}'.format(t['assigned_address_id'])) + elif t['direction'] == 'tx_direction_sent': + for txo in t['output_txos']: + amount = _format_mob(pmob2mob(txo['value_pmob'])) + print('Sent {}'.format(amount)) + if not txo['recipient_address_id']: + print(' to an unknown address.') + else: + print(' to {}'.format(txo['recipient_address_id'])) + print(' in block {}'.format(block_key(t)), end=', ') + if t['fee_pmob'] is None: + print('paying an unknown fee.') + else: + print('paying a fee of {}'.format(_format_mob(pmob2mob(t['fee_pmob'])))) + print() + + def send(self, account_id, amount, to_address, build_only=False, fee=None): + account = self._load_account_prefix(account_id) + account_id = account['account_id'] + + balance = self.client.get_balance_for_account(account_id) + unspent = pmob2mob(balance['unspent_pmob']) + + network_status = self.client.get_network_status() + + if fee is None: + fee = pmob2mob(network_status['fee_pmob']) + else: + fee = Decimal(fee) + + if amount == "all": + amount = unspent - fee + total_amount = unspent + else: + amount = Decimal(amount) + total_amount = amount + fee + + if unspent < total_amount: + print('There is not enough MOB in account {} to pay for this transaction.'.format(account_id[:6])) + return + + if account['view_only']: + verb = 'Building unsigned transaction for' + elif build_only: + verb = 'Building transaction for' + else: + verb = 'Sending' + + print('\n'.join([ + '', + '{} {}', + ' from account {}', + ' to address {}', + 'Fee is {}, for a total amount of {}.', + '', + ]).format( + verb, + _format_mob(amount), + _format_account_header(account), + to_address, + _format_mob(fee), + _format_mob(total_amount), + )) + + if total_amount > unspent: + print('\n'.join([ + 'Cannot send this transaction, because the account only', + 'contains {}. Try sending all funds by entering amount as "all".', + ]).format(_format_mob(unspent))) + return + + if account['view_only']: + response = self.client.build_unsigned_transaction(account_id, amount, to_address, fee=fee) + path = Path('tx_proposal_{}_{}_unsigned.json'.format( + account_id[:6], + balance['local_block_height'], + )) + if path.exists(): + print(f'The file {path} already exists. Please rename the existing file and retry.') + else: + _save_json_file(path, response) + print(f'Wrote {path}.') + print() + print('This account is view-only, so its spend key is in an offline signer.') + print('Run `mob-transaction-signer sign`, then submit the result with `mobcli submit`') + return + + if build_only: + tx_proposal = self.client.build_transaction(account_id, amount, to_address, fee=fee) + path = Path('tx_proposal.json') + if path.exists(): + print(f'The file {path} already exists. Please rename the existing file and retry.') + else: + with path.open('w') as f: + json.dump(tx_proposal, f, indent=2) + print(f'Wrote {path}.') + return + + if not self.confirm('Confirm? (Y/N) '): + print('Cancelled.') + return + + transaction_log, tx_proposal = self.client.build_and_submit_transaction_with_proposal( + account_id, + amount, + to_address, + fee=fee, + ) + + print('Sent {}, with a transaction fee of {}'.format( + _format_mob(pmob2mob(transaction_log['value_pmob'])), + _format_mob(pmob2mob(transaction_log['fee_pmob'])), + )) + + def submit(self, proposal, account_id=None, receipt=False): + if account_id is not None: + account = self._load_account_prefix(account_id) + account_id = account['account_id'] + + with Path(proposal).open() as f: + tx_proposal = json.load(f) + + # Check whether this is an already built response from the offline transaction signer. + if tx_proposal.get('method') == 'submit_transaction': + account_id = tx_proposal['params']['account_id'] + tx_proposal = tx_proposal['params']['tx_proposal'] + + # Check that the tombstone block is within range. + tombstone_block = int(tx_proposal['tx']['prefix']['tombstone_block']) + network_status = self.client.get_network_status() + lo = int(network_status['network_block_height']) + 1 + hi = lo + MAX_TOMBSTONE_BLOCKS - 1 + if lo >= tombstone_block: + print('This transaction has expired, and can no longer be submitted.') + return + if tombstone_block > hi: + print('This transaction cannot be submitted yet. Wait for {} more blocks.'.format( + tombstone_block - hi)) + + # Generate a receipt for the transaction. + if receipt: + receipt = self.client.create_receiver_receipts(tx_proposal) + path = Path('receipt.json') + if path.exists(): + print(f'The file {path} already exists. Please rename the existing file and retry.') + return + else: + with path.open('w') as f: + json.dump(receipt, f, indent=2) + print(f'Wrote {path}.') + + # Confirm and submit. + if account_id is None: + print('This transaction will not be logged, because an account id was not provided.') + total_value = sum( pmob2mob(outlay['value']) for outlay in tx_proposal['outlay_list'] ) + if not self.confirm( + 'Submit this transaction proposal for {}? (Y/N) '.format(_format_mob(total_value)) + ): + print('Cancelled.') + return + + self.client.submit_transaction(tx_proposal, account_id) + print('Submitted. The file {} is now unusable for sending transactions.'.format(proposal)) + + def qr(self, account_id): + try: + import segno + except ImportError: + print('Showing QR codes requires the segno library. Try:') + print('$ pip install segno') + return + + account = self._load_account_prefix(account_id) + account_id = account['account_id'] + + mob_url = 'mob:///b58/{}'.format(account['main_address']) + qr = segno.make(mob_url) + try: + qr.terminal(compact=True) + except TypeError: + qr.terminal() + + print() + _print_account(account) + print() + + def address(self, action, **args): + try: + getattr(self, 'address_' + action)(**args) + except TypeError: + self.address_args.print_help() + + def address_list(self, account_id): + account = self._load_account_prefix(account_id) + print() + print(_format_account_header(account)) + + addresses = self.client.get_addresses_for_account(account['account_id'], limit=1000) + for address in addresses.values(): + balance = self.client.get_balance_for_address(address['public_address']) + print(indent( + '{} {}'.format(address['public_address'], address['metadata']), + ' '*2, + )) + print(indent( + _format_balance(balance), + ' '*4, + )) + + print() + + def address_create(self, account_id, metadata): + account = self._load_account_prefix(account_id) + address = self.client.assign_address_for_account(account['account_id'], metadata) + print() + print(_format_account_header(account)) + print(indent( + '{} {}'.format(address['public_address'], address['metadata']), + ' '*2, + )) + print() + + def gift(self, action, **args): + getattr(self, 'gift_' + action)(**args) + + def gift_list(self): + gift_codes = self.client.get_all_gift_codes() + if gift_codes == []: + print('No gift codes.') + else: + for gift_code in gift_codes: + response = self.client.check_gift_code_status(gift_code['gift_code_b58']) + print() + _print_gift_code( + gift_code['gift_code_b58'], + pmob2mob(gift_code['value_pmob']), + gift_code['memo'], + response['gift_code_status'], + ) + print() + + def gift_create(self, account_id, amount, memo=''): + account = self._load_account_prefix(account_id) + amount = Decimal(amount) + response = self.client.build_gift_code(account['account_id'], amount, memo) + gift_code_b58 = response['gift_code_b58'] + tx_proposal = response['tx_proposal'] + + print() + _print_gift_code(gift_code_b58, amount, memo) + print() + if not self.confirm( + 'Send {} into this new gift code? (Y/N) '.format( + _format_mob(amount), + ) + ): + print('Cancelled.') + return + + gift_code = self.client.submit_gift_code(gift_code_b58, tx_proposal, account['account_id']) + print('Created gift code {}'.format(gift_code['gift_code_b58'])) + + def gift_claim(self, account_id, gift_code): + account = self._load_account_prefix(account_id) + response = self.client.check_gift_code_status(gift_code) + amount = pmob2mob(response['gift_code_value']) + status = response['gift_code_status'] + memo = response.get('gift_code_memo', '') + + if status == 'GiftCodeClaimed': + print('This gift code has already been claimed.') + return + + print() + _print_gift_code(gift_code, amount, memo, status) + print() + _print_account(account) + print() + + if not self.confirm('Claim this gift code for this account? (Y/N) '): + print('Cancelled.') + return + + try: + self.client.claim_gift_code(account['account_id'], gift_code) + except WalletAPIError as e: + if e.response['data']['server_error'] == 'GiftCodeClaimed': + print('This gift code has already been claimed.') + return + + print('Successfully claimed!') + + def gift_remove(self, gift_code): + gift_code_b58 = gift_code + + try: + gift_code = self.client.get_gift_code(gift_code_b58) + response = self.client.check_gift_code_status(gift_code_b58) + + amount = pmob2mob(response['gift_code_value']) + status = response['gift_code_status'] + memo = response.get('gift_code_memo', '') + print() + _print_gift_code(gift_code_b58, amount, memo, status) + print() + + if status == 'GiftCodeAvailable': + print('\n'.join([ + 'This gift code is still available to be claimed.', + 'If you remove it and lose the code, then the funds in the gift code will be lost.', + ])) + if not self.confirm('Continue? (Y/N) '): + print('Cancelled.') + return + + removed = self.client.remove_gift_code(gift_code_b58) + assert removed is True + print('Removed gift code {}'.format(gift_code_b58)) + + except WalletAPIError as e: + if 'GiftCodeNotFound' in e.response['data']['server_error']: + print('Gift code not found; nothing to remove.') + return + + def sync(self, account_id_or_sync_response): + if account_id_or_sync_response.endswith('.json'): + sync_response = account_id_or_sync_response + self._finish_sync(sync_response) + else: + account_id = account_id_or_sync_response + self._start_sync(account_id) + + def _start_sync(self, account_id): + account = self._load_account_prefix(account_id) + print() + print(_format_account_header(account)) + + account_id = account['account_id'] + response = self.client.create_view_only_account_sync_request(account_id) + + network_status = self.client.get_network_status() + filename = 'sync_request_{}_{}.json'.format(account_id[:6], network_status['local_block_height']) + _save_json_file(filename, response) + + print(f'Wrote {filename}.') + + def _finish_sync(self, sync_response): + with open(sync_response) as f: + data = json.load(f) + + self.client.sync_view_only_account(data['params']) + account_id = data['params']['account_id'] + account = self.client.get_account(account_id) + balance = self.client.get_balance_for_account(account_id) + + print() + print('Synced {} transaction outputs.'.format(len(data['params']['completed_txos']))) + print() + _print_account(account, balance) + + def get_account(self, account_id): + r = self._req({ + "method": "get_account", + "params": {"account_id": account_id} + }) + return r['account'] + + def version(self): + version = self.client.version() + print('MobileCoin full-service', version['string']) + print('commit', version['commit'][:6]) + + +def _format_mob(mob): + return '{} MOB'.format(_format_decimal(mob)) + + +def _format_decimal(d): + # Adapted from https://stackoverflow.com/questions/11227620/drop-trailing-zeros-from-decimal + d = Decimal(d) + normalized = d.normalize() + sign, digit, exponent = normalized.as_tuple() + result = normalized if exponent <= 0 else normalized.quantize(1) + return '{:f}'.format(result) + + +def _format_account_header(account): + output = account['account_id'][:6] + if account['name']: + output += ' ' + account['name'] + if account['view_only']: + output += ' [view-only]' + return output + + +def _format_balance(balance): + offline = False + network_block = int(balance['network_block_height']) + if network_block == 0: + offline = True + network_block = int(balance['local_block_height']) + + 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: + sync_status = 'synced' + else: + sync_status = 'syncing, {}/{}'.format(balance['account_block_height'], network_block) + + if offline: + offline_status = ' [offline]' + else: + offline_status = '' + + if 'unspent_pmob' in balance: + amount = balance['unspent_pmob'] + elif 'balance' in balance: + amount = balance['balance'] + + result = '{}{} ({}){}'.format( + _format_mob(pmob2mob(amount)), + orphaned_status, + sync_status, + offline_status, + ) + return result + + +def _format_gift_code_status(status): + return { + 'GiftCodeSubmittedPending': 'pending', + 'GiftCodeAvailable': 'available', + 'GiftCodeClaimed': 'claimed', + }[status] + + +def _print_account(account, balance=None): + print(_format_account_header(account)) + if 'main_address' in account: + print(indent( + 'address {}'.format(account['main_address']), + ' '*2, + )) + if balance is not None: + print(indent( + _format_balance(balance), + ' '*2, + )) + + +def _print_gift_code(gift_code_b58, amount, memo='', status=None): + lines = [] + lines.append(_format_mob(amount)) + if memo: + lines.append(memo) + if status is not None: + lines.append('({})'.format(_format_gift_code_status(status))) + print(gift_code_b58) + print(indent('\n'.join(lines), ' '*2)) + + +def _print_txo(txo, received=False): + print(txo) + to_address = txo['assigned_address'] + if received: + verb = 'Received' + else: + verb = 'Spent' + print(' {} {}'.format(verb, _format_mob(pmob2mob(txo['value_pmob'])))) + if received: + if int(txo['subaddress_index']) == 1: + print(' as change') + else: + print(' at subaddress {}, {}'.format( + txo['subaddress_index'], + to_address, + )) + else: + print(' to unknown address') + + +def _save_export(account, secrets, filename): + export_data = {} + + mnemonic = secrets.get('mnemonic') + if mnemonic is not None: + export_data['mnemonic'] = mnemonic + export_data['key_derivation_version'] = secrets['key_derivation_version'] + legacy_root_entropy = secrets.get('entropy') + if legacy_root_entropy is not None: + export_data['root_entropy'] = legacy_root_entropy + + export_data.update({ + 'account_id': account['account_id'], + 'name': account['name'], + 'account_key': secrets['account_key'], + 'first_block_index': account['first_block_index'], + '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, + { + '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(data, f, indent=2) + f.write('\n') diff --git a/python/mobilecoin/client.py b/python/mobilecoin/client.py new file mode 100644 index 000000000..e83119c6f --- /dev/null +++ b/python/mobilecoin/client.py @@ -0,0 +1,459 @@ +from decimal import Decimal +import http.client +import json +import time +from urllib.parse import urlparse + +DEFAULT_URL = 'http://127.0.0.1:9090/wallet' + +MAX_TOMBSTONE_BLOCKS = 100 + + +class WalletAPIError(Exception): + def __init__(self, response): + self.response = response + + +class Client: + + def __init__(self, url=None, verbose=False): + if url is None: + url = DEFAULT_URL + self.url = url + self.verbose = verbose + self._query_count = 0 + + def _req(self, request_data): + default_params = { + "jsonrpc": "2.0", + "api_version": "2", + "id": 1, + } + request_data = {**request_data, **default_params} + + if self.verbose: + print('POST', self.url) + print(json.dumps(request_data, indent=2)) + print() + + try: + parsed_url = urlparse(self.url) + connection = http.client.HTTPConnection(parsed_url.netloc) + connection.request('POST', parsed_url.path, json.dumps(request_data), {'Content-Type': 'application/json'}) + r = connection.getresponse() + + except ConnectionError: + raise ConnectionError(f'Could not connect to wallet server at {self.url}.') + + raw_response = None + try: + raw_response = r.read() + response_data = json.loads(raw_response) + except ValueError: + raise ValueError('API returned invalid JSON:', raw_response) + + if self.verbose: + print(r.status, http.client.responses[r.status]) + print(len(raw_response), 'bytes') + print(json.dumps(response_data, indent=2)) + print() + + # Check for errors and unwrap result. + try: + result = response_data['result'] + except KeyError: + raise WalletAPIError(response_data) + + self._query_count += 1 + + return result + + def create_account(self, name=None): + r = self._req({ + "method": "create_account", + "params": { + "name": name, + } + }) + return r['account'] + + def import_account(self, mnemonic, key_derivation_version=2, name=None, first_block_index=None, next_subaddress_index=None, fog_keys=None): + params = { + 'mnemonic': mnemonic, + 'key_derivation_version': str(int(key_derivation_version)), + } + if name is not None: + params['name'] = name + if first_block_index is not None: + params['first_block_index'] = str(int(first_block_index)) + if next_subaddress_index is not None: + params['next_subaddress_index'] = str(int(next_subaddress_index)) + if fog_keys is not None: + params.update(fog_keys) + + r = self._req({ + "method": "import_account", + "params": params + }) + return r['account'] + + def import_account_from_legacy_root_entropy(self, legacy_root_entropy, name=None, first_block_index=None, next_subaddress_index=None, fog_keys=None): + params = { + 'entropy': legacy_root_entropy, + } + if name is not None: + params['name'] = name + if first_block_index is not None: + params['first_block_index'] = str(int(first_block_index)) + if next_subaddress_index is not None: + params['next_subaddress_index'] = str(int(next_subaddress_index)) + if fog_keys is not None: + params.update(fog_keys) + + r = self._req({ + "method": "import_account_from_legacy_root_entropy", + "params": params + }) + return r['account'] + + def import_view_only_account(self, params): + r = self._req({ + "method": "import_view_only_account", + "params": params, + }) + return r['account'] + + def get_account(self, account_id): + r = self._req({ + "method": "get_account", + "params": {"account_id": account_id} + }) + return r['account'] + + def get_all_accounts(self): + r = self._req({"method": "get_all_accounts"}) + return r['account_map'] + + def update_account_name(self, account_id, name): + r = self._req({ + "method": "update_account_name", + "params": { + "account_id": account_id, + "name": name, + } + }) + return r['account'] + + def remove_account(self, account_id): + return self._req({ + "method": "remove_account", + "params": {"account_id": account_id} + }) + + def export_account_secrets(self, account_id): + r = self._req({ + "method": "export_account_secrets", + "params": {"account_id": account_id} + }) + return r['account_secrets'] + + def get_txos_for_account(self, account_id, offset=0, limit=100): + r = self._req({ + "method": "get_txos_for_account", + "params": { + "account_id": account_id, + "offset": str(int(offset)), + "limit": str(int(limit)), + } + }) + return r['txo_map'] + + def get_txo(self, txo_id): + r = self._req({ + "method": "get_txo", + "params": { + "txo_id": txo_id, + }, + }) + return r['txo'] + + def get_network_status(self): + r = self._req({ + "method": "get_network_status", + }) + return r['network_status'] + + def get_balance_for_account(self, account_id): + r = self._req({ + "method": "get_balance_for_account", + "params": { + "account_id": account_id, + } + }) + return r['balance'] + + def get_balance_for_address(self, address): + r = self._req({ + "method": "get_balance_for_address", + "params": { + "address": address, + } + }) + return r['balance'] + + def assign_address_for_account(self, account_id, metadata=None): + if metadata is None: + metadata = '' + + r = self._req({ + "method": "assign_address_for_account", + "params": { + "account_id": account_id, + "metadata": metadata, + }, + }) + return r['address'] + + def get_addresses_for_account(self, account_id, offset=0, limit=100): + r = self._req({ + "method": "get_addresses_for_account", + "params": { + "account_id": account_id, + "offset": str(int(offset)), + "limit": str(int(limit)), + }, + }) + return r['address_map'] + + def build_and_submit_transaction(self, account_id, amount, to_address, fee=None): + r = self._build_and_submit_transaction(account_id, amount, to_address, fee) + return r['transaction_log'] + + def build_and_submit_transaction_with_proposal(self, account_id, amount, to_address, fee=None): + r = self._build_and_submit_transaction(account_id, amount, to_address, fee) + return r['transaction_log'], r['tx_proposal'] + + def _build_and_submit_transaction(self, account_id, amount, to_address, fee): + amount = str(mob2pmob(amount)) + params = { + "account_id": account_id, + "addresses_and_values": [(to_address, amount)], + } + if fee is not None: + params['fee'] = str(mob2pmob(fee)) + r = self._req({ + "method": "build_and_submit_transaction", + "params": params, + }) + return r + + def build_transaction(self, account_id, amount, to_address, tombstone_block=None, fee=None): + amount = str(mob2pmob(amount)) + params = { + "account_id": account_id, + "addresses_and_values": [(to_address, amount)], + } + if tombstone_block is not None: + params['tombstone_block'] = str(int(tombstone_block)) + if fee is not None: + params['fee'] = str(mob2pmob(fee)) + r = self._req({ + "method": "build_transaction", + "params": params, + }) + return r['tx_proposal'] + + def build_unsigned_transaction(self, account_id, amount, to_address, tombstone_block=None, fee=None): + amount = str(mob2pmob(amount)) + params = { + "account_id": account_id, + "recipient_public_address": to_address, + "value_pmob": amount, + } + if tombstone_block is not None: + params['tombstone_block'] = str(int(tombstone_block)) + if fee is not None: + params['fee'] = str(mob2pmob(fee)) + r = self._req({ + "method": "build_unsigned_transaction", + "params": params, + }) + return r + + def submit_transaction(self, tx_proposal, account_id=None): + r = self._req({ + "method": "submit_transaction", + "params": { + "tx_proposal": tx_proposal, + "account_id": account_id, + }, + }) + return r['transaction_log'] + + def get_transaction_logs_for_account(self, account_id, offset=0, limit=100): + r = self._req({ + "method": "get_transaction_logs_for_account", + "params": { + "account_id": account_id, + "offset": str(int(offset)), + "limit": str(int(limit)), + }, + }) + return r['transaction_log_map'] + + def create_receiver_receipts(self, tx_proposal): + r = self._req({ + "method": "create_receiver_receipts", + "params": { + "tx_proposal": tx_proposal, + }, + }) + return r['receiver_receipts'] + + def check_receiver_receipt_status(self, address, receipt): + r = self._req({ + "method": "check_receiver_receipt_status", + "params": { + "address": address, + "receiver_receipt": receipt, + } + }) + return r + + def build_gift_code(self, account_id, amount, memo=""): + amount = str(mob2pmob(amount)) + r = self._req({ + "method": "build_gift_code", + "params": { + "account_id": account_id, + "value_pmob": amount, + "memo": memo, + }, + }) + return r + + def submit_gift_code(self, gift_code_b58, tx_proposal, account_id): + r = self._req({ + "method": "submit_gift_code", + "params": { + "gift_code_b58": gift_code_b58, + "tx_proposal": tx_proposal, + "from_account_id": account_id, + }, + }) + return r['gift_code'] + + def get_gift_code(self, gift_code_b58): + r = self._req({ + "method": "get_gift_code", + "params": { + "gift_code_b58": gift_code_b58, + }, + }) + return r['gift_code'] + + def check_gift_code_status(self, gift_code_b58): + r = self._req({ + "method": "check_gift_code_status", + "params": { + "gift_code_b58": gift_code_b58, + }, + }) + return r + + def get_all_gift_codes(self): + r = self._req({ + "method": "get_all_gift_codes", + }) + return r['gift_codes'] + + def claim_gift_code(self, account_id, gift_code_b58): + r = self._req({ + "method": "claim_gift_code", + "params": { + "account_id": account_id, + "gift_code_b58": gift_code_b58, + }, + }) + return r['txo_id'] + + def remove_gift_code(self, gift_code_b58): + r = self._req({ + "method": "remove_gift_code", + "params": { + "gift_code_b58": gift_code_b58, + }, + }) + return r['removed'] + + def create_view_only_account_sync_request(self, account_id): + r = self._req({ + "method": "create_view_only_account_sync_request", + "params": { + "account_id": account_id, + }, + }) + return r + + def sync_view_only_account(self, params): + r = self._req({ + "method": "sync_view_only_account", + "params": params, + }) + return r + + def version(self): + r = self._req({"method": "version"}) + return r + + # Utility methods. + + def poll_balance(self, account_id, min_block_height=None, seconds=10, poll_delay=1.0): + for _ in range(seconds): + balance = self.get_balance_for_account(account_id) + if balance['is_synced']: + if ( + min_block_height is None + or int(balance['account_block_height']) >= min_block_height + ): + return balance + time.sleep(poll_delay) + else: + raise Exception('Could not sync account {}'.format(account_id)) + + def poll_gift_code_status(self, gift_code_b58, target_status, seconds=10, poll_delay=1.0): + for _ in range(seconds): + response = self.check_gift_code_status(gift_code_b58) + if response['gift_code_status'] == target_status: + return response + time.sleep(poll_delay) + else: + raise Exception('Gift code {} never reached status {}.'.format(gift_code_b58, target_status)) + + def poll_txo(self, txo_id, seconds=10, poll_delay=1.0): + for _ in range(10): + try: + return self.get_txo(txo_id) + except WalletAPIError: + pass + time.sleep(poll_delay) + else: + raise Exception('Txo {} never landed.'.format(txo_id)) + + +PMOB = Decimal("1e12") + + +def mob2pmob(x): + """Convert from MOB to picoMOB.""" + result = round(Decimal(x) * PMOB) + assert 0 <= result < 2**64 + return result + + +def pmob2mob(x): + """Convert from picoMOB to MOB.""" + result = int(x) / PMOB + if result == 0: + result = Decimal("0") + return result diff --git a/python-utils/mc_util/__init__.py b/python/mobilecoin/util/__init__.py similarity index 100% rename from python-utils/mc_util/__init__.py rename to python/mobilecoin/util/__init__.py diff --git a/python-utils/mc_util/external_pb2.py b/python/mobilecoin/util/external_pb2.py similarity index 100% rename from python-utils/mc_util/external_pb2.py rename to python/mobilecoin/util/external_pb2.py diff --git a/python-utils/mc_util/printable_pb2.py b/python/mobilecoin/util/printable_pb2.py similarity index 100% rename from python-utils/mc_util/printable_pb2.py rename to python/mobilecoin/util/printable_pb2.py diff --git a/python-utils/setup.py b/python/mobilecoin/util/setup.py similarity index 100% rename from python-utils/setup.py rename to python/mobilecoin/util/setup.py diff --git a/python/poetry.lock b/python/poetry.lock new file mode 100644 index 000000000..fb4fadc06 --- /dev/null +++ b/python/poetry.lock @@ -0,0 +1,57 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + +[[package]] +name = "base58" +version = "2.1.1" +description = "Base58 and Base58Check implementation." +category = "main" +optional = false +python-versions = ">=3.5" +files = [ + {file = "base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2"}, + {file = "base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c"}, +] + +[package.extras] +tests = ["PyHamcrest (>=2.0.2)", "mypy", "pytest (>=4.6)", "pytest-benchmark", "pytest-cov", "pytest-flake8"] + +[[package]] +name = "protobuf" +version = "4.21.12" +description = "" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "protobuf-4.21.12-cp310-abi3-win32.whl", hash = "sha256:b135410244ebe777db80298297a97fbb4c862c881b4403b71bac9d4107d61fd1"}, + {file = "protobuf-4.21.12-cp310-abi3-win_amd64.whl", hash = "sha256:89f9149e4a0169cddfc44c74f230d7743002e3aa0b9472d8c28f0388102fc4c2"}, + {file = "protobuf-4.21.12-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:299ea899484ee6f44604deb71f424234f654606b983cb496ea2a53e3c63ab791"}, + {file = "protobuf-4.21.12-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:d1736130bce8cf131ac7957fa26880ca19227d4ad68b4888b3be0dea1f95df97"}, + {file = "protobuf-4.21.12-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:78a28c9fa223998472886c77042e9b9afb6fe4242bd2a2a5aced88e3f4422aa7"}, + {file = "protobuf-4.21.12-cp37-cp37m-win32.whl", hash = "sha256:3d164928ff0727d97022957c2b849250ca0e64777ee31efd7d6de2e07c494717"}, + {file = "protobuf-4.21.12-cp37-cp37m-win_amd64.whl", hash = "sha256:f45460f9ee70a0ec1b6694c6e4e348ad2019275680bd68a1d9314b8c7e01e574"}, + {file = "protobuf-4.21.12-cp38-cp38-win32.whl", hash = "sha256:6ab80df09e3208f742c98443b6166bcb70d65f52cfeb67357d52032ea1ae9bec"}, + {file = "protobuf-4.21.12-cp38-cp38-win_amd64.whl", hash = "sha256:1f22ac0ca65bb70a876060d96d914dae09ac98d114294f77584b0d2644fa9c30"}, + {file = "protobuf-4.21.12-cp39-cp39-win32.whl", hash = "sha256:27f4d15021da6d2b706ddc3860fac0a5ddaba34ab679dc182b60a8bb4e1121cc"}, + {file = "protobuf-4.21.12-cp39-cp39-win_amd64.whl", hash = "sha256:237216c3326d46808a9f7c26fd1bd4b20015fb6867dc5d263a493ef9a539293b"}, + {file = "protobuf-4.21.12-py2.py3-none-any.whl", hash = "sha256:a53fd3f03e578553623272dc46ac2f189de23862e68565e83dde203d41b76fc5"}, + {file = "protobuf-4.21.12-py3-none-any.whl", hash = "sha256:b98d0148f84e3a3c569e19f52103ca1feacdac0d2df8d6533cf983d1fda28462"}, + {file = "protobuf-4.21.12.tar.gz", hash = "sha256:7cd532c4566d0e6feafecc1059d04c7915aec8e182d1cf7adee8b24ef1e2e6ab"}, +] + +[[package]] +name = "segno" +version = "1.5.2" +description = "QR Code and Micro QR Code generator for Python 2 and Python 3" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +files = [ + {file = "segno-1.5.2-py2.py3-none-any.whl", hash = "sha256:b17ace8171aad3987e01bb4aeadf7e0450c40674024c4c57b4da54028e55f1e9"}, + {file = "segno-1.5.2.tar.gz", hash = "sha256:983424b296e62189d70fc73460cd946cf56dcbe82b9bda18c066fc1b24371cdc"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "47faef9f8f302e3e8c733d5fa8e9861e6b5105c0221938dad62d51c2132ed9cf" diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 000000000..9b9343735 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "mobilecoin" +version = "2.1.2" +description = "" +authors = ["Christian Oudard "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.10" +segno = "^1.5.2" +base58 = "^2.1.1" +protobuf = "^4.21.12" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/tests/client_tests.py b/python/tests/client_tests.py new file mode 100644 index 000000000..72052766f --- /dev/null +++ b/python/tests/client_tests.py @@ -0,0 +1,318 @@ +from contextlib import contextmanager +from decimal import Decimal +import sys +import tempfile +import time + +from mobilecoin import ( + Client, + WalletAPIError, + pmob2mob, +) +from mobilecoin.cli import ( + CommandLineInterface, + _load_import, +) + + +def main(): + c = Client(verbose=False) + + source_wallet = sys.argv[1] + + cli = _start_test_server() + + # Start and end with an empty wallet. + try: + check_wallet_empty(c) + + test_errors(c) + test_account_management(c) + tests_with_wallet(c, source_wallet) + + check_wallet_empty(c) + except Exception: + print('FAIL') + raise + else: + print('ALL PASS') + cli.stop() # Only stop the server if there were no errors. + + +def test_errors(c): + print('\ntest_errors') + + try: + c.get_account('invalid') + except WalletAPIError: + pass + else: + raise AssertionError() + + print('PASS') + + +def test_account_management(c): + print('\ntest_account_management') + + # Create an account. + account = c.create_account() + account_id = account['account_id'] + + # Get accounts. + account_2 = c.get_account(account_id) + assert account == account_2 + + accounts = c.get_all_accounts() + account_ids = list(accounts.keys()) + assert account_ids == [account_id] + assert accounts[account_id] == account + + # Rename account. + assert account['name'] == '' + c.update_account_name(account_id, 'X') + account = c.get_account(account_id) + assert account['name'] == 'X' + + # Remove the created account. + c.remove_account(account_id) + + # Import an account from entropy. + entropy = '0000000000000000000000000000000000000000000000000000000000000000' + account = c.import_account_from_legacy_root_entropy(entropy) + account_id = account['account_id'] + assert ( + account['main_address'] + == '6UEtkm1rieLhuz2wvELPHdGiCb96zNnW856QVeGLvYzE7NhmbG1MxnoSPGqyVfEHDvxzQmaURFpZcxT9TSypVgRVAusr7svtD1TcrYj92Uh' + ) + + # Export secrets. + secrets = c.export_account_secrets(account_id) + assert secrets['entropy'] == entropy + assert ( + secrets['account_key']['view_private_key'] + == '0a20b0146de8cd8f5b7962f9e74a5ef0f3e58a9550c9527ac144f38729f0fd3fed0e' + ) + assert ( + secrets['account_key']['spend_private_key'] + == '0a20b4bf01a77ed4e065e9082d4bda67add30c88e021dcf81fc84e6a9ca2cb68e107' + ) + c.remove_account(account_id) + + print('PASS') + + +def tests_with_wallet(c, source_wallet): + print('\nLoading source wallet', source_wallet) + + # Import an account with money. + data = _load_import(source_wallet) + source_account = c.import_account(**data) + source_account_id = source_account['account_id'] + + # Check its balance and make sure it has txos. + balance = c.poll_balance(source_account_id, seconds=60) + assert pmob2mob(balance['unspent_pmob']) >= 1 + txos = c.get_txos_for_account(source_account_id) + assert len(txos) > 0 + + try: + test_transaction(c, source_account_id) + test_prepared_transaction(c, source_account_id) + test_subaddresses(c, source_account_id) + test_gift_codes(c, source_account_id) + except Exception: + raise + else: + c.remove_account(source_account['account_id']) + + +def test_transaction(c, source_account_id): + print('\ntest_transaction') + + source_account = c.get_account(source_account_id) + + # Create a temporary account to transact with. + dest_account = c.create_account() + dest_account_id = dest_account['account_id'] + + # Send transactions and ensure they show up in the transaction list. + transaction_log = c.build_and_submit_transaction(source_account_id, 0.1, dest_account['main_address']) + tx_index = int(transaction_log['submitted_block_index']) + balance = c.poll_balance(dest_account_id, tx_index + 1) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.1') + + # Send back the remaining money. + transaction_log = c.build_and_submit_transaction(dest_account_id, 0.0996, source_account['main_address']) + tx_index = int(transaction_log['submitted_block_index']) + balance = c.poll_balance(dest_account_id, tx_index + 1) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.0') + + # Check transaction logs. + transaction_log_map = c.get_transaction_logs_for_account(dest_account_id) + amounts = [ pmob2mob(t['value_pmob']) for t in transaction_log_map.values() ] + assert sorted( float(a) for a in amounts ) == [0.0996, 0.1], str(amounts) + assert all( t['status'] == 'tx_status_succeeded' for t in transaction_log_map.values() ) + + c.remove_account(dest_account_id) + + print('PASS') + + +def test_prepared_transaction(c, source_account_id): + print('\ntest_prepared_transaction') + + source_account = c.get_account(source_account_id) + + # Create a temporary account. + dest_account = c.create_account() + dest_account_id = dest_account['account_id'] + + # Send a prepared transaction with a receipt. + tx_proposal = c.build_transaction(source_account_id, 0.1, dest_account['main_address']) + assert len(tx_proposal['outlay_list']) == 1 + receipts = c.create_receiver_receipts(tx_proposal) + assert len(receipts) == 1 + receipt = receipts[0] + + status = c.check_receiver_receipt_status(dest_account['main_address'], receipt) + assert status['receipt_transaction_status'] == 'TransactionPending' + + transaction_log = c.submit_transaction(tx_proposal, source_account_id) + tx_index = int(transaction_log['submitted_block_index']) + + balance = c.poll_balance(dest_account_id, tx_index + 1) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.1') + + status = c.check_receiver_receipt_status(dest_account['main_address'], receipt) + assert status['receipt_transaction_status'] == 'TransactionSuccess' + + # Send back the remaining money. + transaction_log = c.build_and_submit_transaction(dest_account_id, 0.0996, source_account['main_address']) + tx_index = int(transaction_log['submitted_block_index']) + balance = c.poll_balance(dest_account_id, tx_index + 1) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.0') + + c.remove_account(dest_account_id) + + print('PASS') + + +def test_subaddresses(c, source_account_id): + print('\ntest_subaddresses') + + addresses = c.get_addresses_for_account(source_account_id) + source_address = list(addresses.keys())[0] + + # Create a temporary account. + dest_account = c.create_account() + dest_account_id = dest_account['account_id'] + + # Create a subaddress for the destination account. + addresses = c.get_addresses_for_account(dest_account_id) + assert len(addresses) == 2 # Main address and change address. + + address = c.assign_address_for_account(dest_account_id, 'Address Name') + dest_address = address['public_address'] + + addresses = c.get_addresses_for_account(dest_account_id) + assert len(addresses) == 3 + assert addresses[dest_address]['metadata'] == 'Address Name' + + # Send the subaddress some money. + transaction_log, tx_proposal = c.build_and_submit_transaction_with_proposal(source_account_id, 0.1, dest_address) + tx_index = int(transaction_log['submitted_block_index']) + balance = c.poll_balance(dest_account_id, tx_index + 1) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.1') + assert len(tx_proposal['outlay_list']) == 1 + receipts = c.create_receiver_receipts(tx_proposal) + assert len(receipts) == 1 + + # The second address has money credited to it, but the main one doesn't. + balance = c.get_balance_for_address(dest_address) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.1') + balance = c.get_balance_for_address(dest_account['main_address']) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.0') + + # Send the money back. + transaction_log = c.build_and_submit_transaction(dest_account_id, 0.0996, source_address) + tx_index = int(transaction_log['submitted_block_index']) + balance = c.poll_balance(dest_account_id, tx_index + 1) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.0') + + # The per-address balances account for sent funds. + balance = c.get_balance_for_address(dest_account['main_address']) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.0') + balance = c.get_balance_for_address(dest_address) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.0') + + c.remove_account(dest_account_id) + + print('PASS') + + +def test_gift_codes(c, source_account_id): + print('\ntest_gift_codes') + + source_account = c.get_account(source_account_id) + + # Create a gift code. + response = c.build_gift_code(source_account_id, 0.1, 'abc') + gift_code_b58 = response['gift_code_b58'] + tx_proposal = response['tx_proposal'] + c.submit_gift_code(gift_code_b58, tx_proposal, source_account_id) + + # Make sure the gift code was funded correctly. + response = c.poll_gift_code_status(gift_code_b58, 'GiftCodeAvailable') + assert pmob2mob(response['gift_code_value']) == Decimal('0.1') + + # Create a temporary account. + dest_account = c.create_account() + dest_account_id = dest_account['account_id'] + + # Claim the gift code. + # Claimed means the txo was sent, not that it arrived. Poll for the Txo to land. + txo_id_hex = c.claim_gift_code(dest_account_id, gift_code_b58) + c.poll_txo(txo_id_hex) + balance = c.get_balance_for_account(dest_account_id) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.0996') + + # Send back the remaining money. We incurred two fees to submit and claim the gift code. + transaction_log = c.build_and_submit_transaction(dest_account_id, 0.0992, source_account['main_address']) + tx_index = int(transaction_log['submitted_block_index']) + balance = c.poll_balance(dest_account_id, tx_index + 1) + assert pmob2mob(balance['unspent_pmob']) == Decimal('0.0') + + c.remove_account(dest_account_id) + + print('PASS') + + +def _start_test_server(): + # Create a test wallet database, and start the server. + db_file = tempfile.NamedTemporaryFile(suffix='.db', prefix='test_wallet_', delete=False) + cli = CommandLineInterface() + cli.config['wallet-db'] = db_file.name + cli.stop() + time.sleep(0.5) # Wait for other servers to stop. + cli.start(bg=True, unencrypted=True) + time.sleep(1.5) # Wait for the server to start listening. + return cli + + +def check_wallet_empty(c): + with quiet(c): + accounts = c.get_all_accounts() + assert accounts == {}, 'Wallet not empty!' + + +@contextmanager +def quiet(c): + old_verbose = c.verbose + c.verbose = False + yield + c.verbose = old_verbose + + +if __name__ == '__main__': + main() diff --git a/python/tests/stress.py b/python/tests/stress.py new file mode 100644 index 000000000..c4e80b29b --- /dev/null +++ b/python/tests/stress.py @@ -0,0 +1,126 @@ +import aiohttp +import asyncio +from time import perf_counter + + +async def main(): + c = StressClient() + await test_account_create(c) + await test_subaddresses(c) + + +async def test_account_create(c, n=10): + accounts = await c.get_all_accounts() + num_accounts_before = len(accounts) + + account_ids = [] + async def create_one(i): + account = await c.create_account(str(i)) + account_ids.append(account['account_id']) + + with Timer() as timer: + await asyncio.gather(*[ + create_one(i) + for i in range(1, n+1) + ]) + + accounts = await c.get_all_accounts() + assert len(accounts) == num_accounts_before + n + + await asyncio.gather(*[ + c.remove_account(account_id) + for account_id in account_ids + ]) + + print('Created {} accounts in {:.3f}s'.format(n, timer.elapsed)) + + +async def test_subaddresses(c, n=10): + account = await c.create_account() + account_id = account['account_id'] + + addresses = await c.get_addresses_for_account(account_id) + assert len(addresses) == 2 + + with Timer() as timer: + await asyncio.gather(*[ + c.assign_address(account_id, str(i)) + for i in range(1, n+1) + ]) + + addresses = await c.get_addresses_for_account(account_id) + assert len(addresses) == 2 + n + + await c.remove_account(account_id) + + print('Created {} addresses in {:.3f}s'.format(n, timer.elapsed)) + + +class StressClient: + + async def _req(self, request_data): + default_params = { + "jsonrpc": "2.0", + "api_version": "2", + "id": 1, + } + request_data = {**request_data, **default_params} + async with aiohttp.ClientSession() as session: + async with session.post('http://localhost:9090/wallet', json=request_data) as response: + r = await response.json() + try: + return r['result'] + except KeyError: + print(r) + raise + + async def get_all_accounts(self): + r = await self._req({"method": "get_all_accounts"}) + return r['account_map'] + + async def create_account(self, name=''): + r = await self._req({ + "method": "create_account", + "params": {"name": name} + }) + return r['account'] + + async def remove_account(self, account_id): + return await self._req({ + "method": "remove_account", + "params": {"account_id": account_id} + }) + + async def assign_address(self, account_id, name=''): + return await self._req({ + "method": "assign_address_for_account", + "params": { + "account_id": account_id, + "metadata": name, + }, + }) + + async def get_addresses_for_account(self, account_id): + r = await self._req({ + "method": "get_addresses_for_account", + "params": { + "account_id": account_id, + "offset": "0", + "limit": "1000", + }, + }) + return r['address_map'] + + +class Timer: + def __enter__(self): + self._start_time = perf_counter() + return self + + def __exit__(self, *_): + end_time = perf_counter() + self.elapsed = end_time - self._start_time + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/python/tests/sync_benchmark.py b/python/tests/sync_benchmark.py new file mode 100644 index 000000000..02087c328 --- /dev/null +++ b/python/tests/sync_benchmark.py @@ -0,0 +1,21 @@ +import sys +import time + +from mobilecoin import Client +from mobilecoin.cli import _load_import + +c = Client(verbose=False) + +source_wallet = sys.argv[1] +data = _load_import(source_wallet) +source_account = c.import_account(**data) +source_account_id = source_account['account_id'] + +start = time.monotonic() +balance = c.poll_balance(source_account_id, seconds=600, poll_delay=0.2) +end = time.monotonic() + +c.remove_account(source_account_id) + +print(round(end - start, 1), 'seconds') +print(int(balance['unspent_pmob']) / 1e12, 'MOB') diff --git a/python/tests/test_display.py b/python/tests/test_display.py new file mode 100644 index 000000000..75184f514 --- /dev/null +++ b/python/tests/test_display.py @@ -0,0 +1,16 @@ +from mobilecoin.cli import _format_decimal + + +def test_format_decimal(): + def f(x): + return str(_format_decimal(x)) + assert f('4200') == '4200' + assert f('42') == '42' + assert f('42.0') == '42' + assert f('42.0000') == '42' + assert f('4.2') == '4.2' + assert f('4.20') == '4.2' + assert f('.42') == '0.42' + assert f('.0042') == '0.0042' + assert f('.004200') == '0.0042' + assert f('.00000042') == '0.00000042'