diff --git a/src/ape_accounts/accounts.py b/src/ape_accounts/accounts.py index 4635b3ec18..ba0dc8cd00 100644 --- a/src/ape_accounts/accounts.py +++ b/src/ape_accounts/accounts.py @@ -9,6 +9,7 @@ from ape.api import AccountAPI, AccountContainerAPI, TransactionAPI from ape.exceptions import AccountsError +from ape.logging import logger from ape.types import AddressType, MessageSignature, SignableMessage, TransactionSignature from ape.utils import to_address @@ -46,6 +47,7 @@ class KeyfileAccount(AccountAPI): keyfile_path: Path locked: bool = True + __autosign: bool = False __cached_key: Optional[HexBytes] = None def __repr__(self): @@ -72,12 +74,7 @@ def __key(self) -> HexBytes: else: self.__cached_key = None - passphrase = click.prompt( - f"Enter Passphrase to unlock '{self.alias}'", - hide_input=True, - default="", # Just in case there's no passphrase - ) - + passphrase = self._prompt_for_passphrase(default="") key = self.__decrypt_keyfile(passphrase) if click.confirm(f"Leave '{self.alias}' unlocked?"): @@ -86,10 +83,9 @@ def __key(self) -> HexBytes: return key - def unlock(self): - passphrase = click.prompt( - f"Enter Passphrase to permanently unlock '{self.alias}'", - hide_input=True, + def unlock(self, passphrase: Optional[str] = None): + passphrase = passphrase or self._prompt_for_passphrase( + f"Enter passphrase to permanently unlock '{self.alias}'" ) self.__cached_key = self.__decrypt_keyfile(passphrase) self.locked = False @@ -101,25 +97,19 @@ def change_password(self): self.locked = True # force entering passphrase to get key key = self.__key - passphrase = click.prompt( - "Create New Passphrase", - hide_input=True, - confirmation_prompt=True, - ) - + passphrase = self._prompt_for_passphrase("Create new passphrase", confirmation_prompt=True) self.keyfile_path.write_text(json.dumps(EthAccount.encrypt(key, passphrase))) def delete(self): - passphrase = click.prompt( - f"Enter Passphrase to delete '{self.alias}'", - hide_input=True, - default="", # Just in case there's no passphrase + passphrase = self._prompt_for_passphrase( + f"Enter passphrase to delete '{self.alias}'", default="" ) self.__decrypt_keyfile(passphrase) self.keyfile_path.unlink() def sign_message(self, msg: SignableMessage) -> Optional[MessageSignature]: - if self.locked and not click.confirm(f"{msg}\n\nSign: "): + user_approves = self.__autosign or click.confirm(f"{msg}\n\nSign: ") + if self.locked and not user_approves: return None signed_msg = EthAccount.sign_message(msg, self.__key) @@ -130,7 +120,8 @@ def sign_message(self, msg: SignableMessage) -> Optional[MessageSignature]: ) def sign_transaction(self, txn: TransactionAPI) -> Optional[TransactionSignature]: - if self.locked and not click.confirm(f"{txn}\n\nSign: "): + user_approves = self.__autosign or click.confirm(f"{txn}\n\nSign: ") + if self.locked and not user_approves: return None signed_txn = EthAccount.sign_transaction( @@ -142,6 +133,27 @@ def sign_transaction(self, txn: TransactionAPI) -> Optional[TransactionSignature s=to_bytes(signed_txn.s), ) + def set_autosign(self, enabled: bool, passphrase: Optional[str] = None): + """ + Allow this account to automatically sign messages and transactions. + + Args: + enabled (bool): ``True`` to enable, ``False`` to disable. + passphrase (Optional[str]): Optionally provide the passphrase. + If not provided, you will be prompted to enter it. + """ + self.unlock(passphrase=passphrase) + logger.warning("Danger! This account will now sign any transaction its given.") + self.__autosign = enabled + + def _prompt_for_passphrase(self, message: Optional[str] = None, **kwargs): + message = message or f"Enter passphrase to unlock '{self.alias}'" + return click.prompt( + message, + hide_input=True, + **kwargs, + ) + def __decrypt_keyfile(self, passphrase: str) -> HexBytes: try: return EthAccount.decrypt(self.keyfile, passphrase) diff --git a/tests/conftest.py b/tests/conftest.py index 8b6c6d76e1..7ae315c66b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import shutil from pathlib import Path from tempfile import mkdtemp @@ -53,3 +54,38 @@ def project_folder(config): @pytest.fixture(scope="session") def project(config): yield ape.Project(config.PROJECT_FOLDER) + + +@pytest.fixture +def keyparams(): + # NOTE: password is 'a' + return { + "address": "7e5f4552091a69125d5dfcb7b8c2659029395bdf", + "crypto": { + "cipher": "aes-128-ctr", + "cipherparams": {"iv": "7bc492fb5dca4fe80fd47645b2aad0ff"}, + "ciphertext": "43beb65018a35c31494f642ec535315897634b021d7ec5bb8e0e2172387e2812", + "kdf": "scrypt", + "kdfparams": { + "dklen": 32, + "n": 262144, + "r": 1, + "p": 8, + "salt": "4b127cb5ddbc0b3bd0cc0d2ef9a89bec", + }, + "mac": "6a1d520975a031e11fc16cff610f5ae7476bcae4f2f598bc59ccffeae33b1caa", + }, + "id": "ee424db9-da20-405d-bd75-e609d3e2b4ad", + "version": 3, + } + + +@pytest.fixture +def temp_accounts_path(config): + path = Path(config.DATA_FOLDER) / "accounts" + path.mkdir(exist_ok=True, parents=True) + + yield path + + if path.exists(): + shutil.rmtree(path) diff --git a/tests/functional/test_accounts.py b/tests/functional/test_accounts.py index 2aacb8bb2d..373f325c35 100644 --- a/tests/functional/test_accounts.py +++ b/tests/functional/test_accounts.py @@ -1,9 +1,30 @@ +import json + import pytest from eth_account.messages import encode_defunct +import ape from ape import convert from ape.exceptions import AccountsError, ContractLogicError, TransactionError +ALIAS = "__FUNCTIONAL_TESTS_ALIAS__" + + +@pytest.fixture +def temp_ape_account(keyparams, temp_accounts_path): + test_keyfile_path = temp_accounts_path / f"{ALIAS}.json" + + if test_keyfile_path.exists(): + # Corrupted from a previous test + test_keyfile_path.unlink() + + test_keyfile_path.write_text(json.dumps(keyparams)) + + yield ape.accounts.load(ALIAS) + + if test_keyfile_path.exists(): + test_keyfile_path.unlink() + def test_sign_message(test_accounts): signer = test_accounts[2] @@ -90,3 +111,10 @@ def test_accounts_address_access(test_accounts, accounts): def test_accounts_contains(accounts, test_accounts): assert test_accounts[0].address in accounts + + +def test_autosign(temp_ape_account): + temp_ape_account.set_autosign(True, passphrase="a") + message = encode_defunct(text="Hello Apes!") + signature = temp_ape_account.sign_message(message) + assert temp_ape_account.check_signature(message, signature) diff --git a/tests/integration/cli/test_accounts.py b/tests/integration/cli/test_accounts.py index bc2962e1ed..1cc75f0a53 100644 --- a/tests/integration/cli/test_accounts.py +++ b/tests/integration/cli/test_accounts.py @@ -1,5 +1,4 @@ import json -from pathlib import Path import pytest from eth_account import Account # type: ignore @@ -13,35 +12,9 @@ GENERATE_VALID_INPUT = "\n".join(["random entropy", PASSWORD, PASSWORD]) -@pytest.fixture -def keyparams(): - # NOTE: password is 'a' - return { - "address": "7e5f4552091a69125d5dfcb7b8c2659029395bdf", - "crypto": { - "cipher": "aes-128-ctr", - "cipherparams": {"iv": "7bc492fb5dca4fe80fd47645b2aad0ff"}, - "ciphertext": "43beb65018a35c31494f642ec535315897634b021d7ec5bb8e0e2172387e2812", - "kdf": "scrypt", - "kdfparams": { - "dklen": 32, - "n": 262144, - "r": 1, - "p": 8, - "salt": "4b127cb5ddbc0b3bd0cc0d2ef9a89bec", - }, - "mac": "6a1d520975a031e11fc16cff610f5ae7476bcae4f2f598bc59ccffeae33b1caa", - }, - "id": "ee424db9-da20-405d-bd75-e609d3e2b4ad", - "version": 3, - } - - @pytest.fixture(autouse=True) -def temp_keyfile_path(config): - temp_accounts_dir = Path(config.DATA_FOLDER) / "accounts" - temp_accounts_dir.mkdir(exist_ok=True, parents=True) - test_keyfile_path = temp_accounts_dir / f"{ALIAS}.json" +def temp_keyfile_path(temp_accounts_path): + test_keyfile_path = temp_accounts_path / f"{ALIAS}.json" if test_keyfile_path.exists(): # Corrupted from a previous test @@ -75,7 +48,7 @@ def test_import(ape_cli, runner, temp_account, temp_keyfile_path): assert temp_keyfile_path.exists() -def test_import_alias_already_in_use(ape_cli, runner, temp_account, temp_keyfile_path): +def test_import_alias_already_in_use(ape_cli, runner, temp_account): def invoke_import(): return runner.invoke(ape_cli, ["accounts", "import", ALIAS], input=IMPORT_VALID_INPUT) @@ -85,9 +58,7 @@ def invoke_import(): assert_failure(result, f"Account with alias '{ALIAS}' already in use") -def test_import_account_instantiation_failure( - mocker, ape_cli, runner, temp_account, temp_keyfile_path -): +def test_import_account_instantiation_failure(mocker, ape_cli, runner, temp_account): eth_account_from_key_patch = mocker.patch("ape_accounts._cli.EthAccount.from_key") eth_account_from_key_patch.side_effect = Exception("Can't instantiate this account!") result = runner.invoke(ape_cli, ["accounts", "import", ALIAS], input=IMPORT_VALID_INPUT) @@ -103,7 +74,7 @@ def test_generate(ape_cli, runner, temp_keyfile_path): assert temp_keyfile_path.exists() -def test_generate_alias_already_in_use(ape_cli, runner, temp_account, temp_keyfile_path): +def test_generate_alias_already_in_use(ape_cli, runner, temp_account): def invoke_generate(): return runner.invoke(ape_cli, ["accounts", "generate", ALIAS], input=GENERATE_VALID_INPUT) @@ -142,6 +113,6 @@ def test_change_password(ape_cli, runner, temp_keyfile): def test_delete(ape_cli, runner, temp_keyfile): assert temp_keyfile.exists() # Delete Account - result = runner.invoke(ape_cli, ["accounts", "delete", ALIAS], input=PASSWORD + "\n") + result = runner.invoke(ape_cli, ["accounts", "delete", ALIAS], input=f"{PASSWORD}\n") assert result.exit_code == 0, result.output assert not temp_keyfile.exists()