diff --git a/autonity_cli/auth.py b/autonity_cli/auth.py index fddb94f..038d18f 100644 --- a/autonity_cli/auth.py +++ b/autonity_cli/auth.py @@ -14,19 +14,15 @@ from eth_utils.conversions import to_int from eth_utils.crypto import keccak from hexbytes import HexBytes -from trezorlib.client import get_default_client from trezorlib.exceptions import Cancelled from trezorlib.messages import Features from trezorlib.tools import parse_path -from trezorlib.transport import DeviceIsBusy from web3.types import TxParams -from . import config +from . import config, device from .logging import log from .utils import to_checksum_address -TREZOR_DEFAULT_PREFIX = "m/44h/60h/0h/0" - class Authenticator(Protocol): address: ChecksumAddress @@ -67,7 +63,7 @@ def sign_message(self, message: str) -> bytes: class TrezorAuthenticator: def __init__(self, path_or_index: str): if path_or_index.isdigit(): - path_str = f"{TREZOR_DEFAULT_PREFIX}/{int(path_or_index)}" + path_str = f"{device.TREZOR_DEFAULT_PREFIX}/{int(path_or_index)}" else: path_str = path_or_index try: @@ -76,20 +72,13 @@ def __init__(self, path_or_index: str): raise click.ClickException( f"Invalid Trezor BIP32 derivation path '{path_str}'." ) from exc - try: - self.client = get_default_client() - except DeviceIsBusy as exc: - raise click.ClickException("Device in use by another process.") from exc - except Exception as exc: - raise click.ClickException( - "No Trezor device found. Check device is connected, unlocked, and detected by OS." - ) from exc + self.client = device.get_client() device_info = self.device_info(self.client.features) log(f"Connected to Trezor: {device_info}") try: address_str = trezor_eth.get_address(self.client, self.path) - except Cancelled as exc: + except Cancelled as exc: # user cancelled optional passphrase prompt raise click.Abort() from exc self.address = to_checksum_address(address_str) diff --git a/autonity_cli/commands/account.py b/autonity_cli/commands/account.py index 78d52eb..1f4fed0 100644 --- a/autonity_cli/commands/account.py +++ b/autonity_cli/commands/account.py @@ -2,6 +2,7 @@ import json from typing import List, Optional +import click import eth_account from autonity import Autonity from click import ClickException, Path, argument, group, option @@ -11,8 +12,11 @@ from web3 import Web3 from web3.types import BlockIdentifier -from .. import config -from ..auth import validate_authenticator, validate_authenticator_account +from .. import config, device +from ..auth import ( + validate_authenticator, + validate_authenticator_account, +) from ..denominations import ( format_auton_quantity, format_newton_quantity, @@ -31,6 +35,7 @@ keyfile_option, keystore_option, newton_or_token_option, + optgroup, rpc_endpoint_option, ) from ..user import get_account_stats @@ -54,20 +59,50 @@ def account_group() -> None: @account_group.command(name="list") -@option("--with-files", is_flag=True, help="also show keyfile names.") -@keystore_option() -def list_cmd(keystore: Optional[str], with_files: bool) -> None: +@optgroup.group("Keyfile accounts") +@keystore_option(cls=optgroup.option) +@optgroup.group("Trezor accounts") +@optgroup.option("--trezor", is_flag=True, help="Enumerate Trezor accounts") +@optgroup.option( + "--prefix", + metavar="PREFIX", + default=device.TREZOR_DEFAULT_PREFIX, + show_default=True, + help="Custom BIP32 derivation prefix", +) +@optgroup.option( + "--start", + type=int, + default=0, + show_default=True, + help="Start index at BIP32 derivation prefix", +) +@optgroup.option( + "-n", + type=int, + default=20, + show_default=True, + help="Number of Trezor accounts to list", +) +def list_cmd( + keystore: Optional[str], trezor: bool, prefix: str, start: int, n: int +) -> None: """ - List the accounts for files in the keystore directory. + List accounts in keyfiles or in a Trezor device. """ - keystore = config.get_keystore_directory(keystore) - keyfiles = address_keyfile_dict(keystore) - for addr, keyfile in keyfiles.items(): - if with_files: - print(addr + " " + keyfile) - else: - print(addr) + if trezor and keystore: + raise click.ClickException( + "Options --trezor and --keystore are mutually exclusive." + ) + + if trezor: + accounts = device.enumerate_accounts(prefix, start, n) + else: + keystore = config.get_keystore_directory(keystore) + accounts = address_keyfile_dict(keystore).items() + for addr, path in accounts: + print(addr + " " + path) @account_group.command() diff --git a/autonity_cli/device.py b/autonity_cli/device.py new file mode 100644 index 0000000..82c8321 --- /dev/null +++ b/autonity_cli/device.py @@ -0,0 +1,47 @@ +"""Hardware wallet common functions. + +Currently only Trezor devices are supported.""" + +import click +import trezorlib.ethereum as trezor_eth +from eth_typing import ChecksumAddress +from trezorlib.client import TrezorClient, get_default_client +from trezorlib.exceptions import Cancelled +from trezorlib.tools import parse_path +from trezorlib.transport import DeviceIsBusy + +from .utils import to_checksum_address + +TREZOR_DEFAULT_PREFIX = "m/44h/60h/0h/0" + + +def get_client() -> TrezorClient: + try: + return get_default_client() + except DeviceIsBusy as exc: + raise click.ClickException("Device in use by another process.") from exc + except Exception as exc: + raise click.ClickException( + "No Trezor device found. Check device is connected, unlocked, and detected by OS." + ) from exc + + +def enumerate_accounts( + prefix: str, start: int, n: int +) -> list[tuple[ChecksumAddress, str]]: + accounts: list[tuple[ChecksumAddress, str]] = [] + client = get_client() + try: + for index in range(start, start + n): + path_str = prefix + f"/{index}" + try: + path = parse_path(path_str) + except ValueError as exc: + raise click.ClickException( + f"Invalid Trezor BIP32 derivation path '{path_str}'." + ) from exc + address_str = trezor_eth.get_address(client, path) + accounts.append((to_checksum_address(address_str), path_str)) + except Cancelled as exc: # user cancelled optional passphrase prompt + raise click.Abort() from exc + return accounts diff --git a/autonity_cli/options.py b/autonity_cli/options.py index 14152e4..a0ebf10 100644 --- a/autonity_cli/options.py +++ b/autonity_cli/options.py @@ -50,6 +50,19 @@ def make_option( help="encrypted private key file (falls back to 'keyfile' in config file).", ) +keystore_option_info = OptionInfo( + args=( + "--keystore", + "-s", + ), + type=Path(exists=True), + help=( + "keystore directory (falls back to 'keystore' in config file, " + "defaults to ~/.autonity/keystore)." + ), +) + + trezor_option_info = OptionInfo( args=["--trezor"], metavar="ACCOUNT", @@ -72,21 +85,13 @@ def make_option( ) -def keystore_option() -> Decorator[Func]: +def keystore_option(cls: Decorator[Any] = click.option) -> Decorator[Func]: """ Option: --keystore . """ def decorator(fn: Func) -> Func: - return click.option( - "--keystore", - "-s", - type=Path(exists=True), - help=( - "keystore directory (falls back to 'keystore' in config file, " - "defaults to ~/.autonity/keystore)." - ), - )(fn) + return make_option(keystore_option_info, cls=cls)(fn) return decorator