Skip to content

Commit

Permalink
feat: enable accounts to be able to auto-sign transactions (#650)
Browse files Browse the repository at this point in the history
* feat: init autosign method

* feat: autosign

* feat: warn

* feat: allow to unlock at same time

* test: add test

* chore: mypy

* test: fix tests

Co-authored-by: El De-dog-lo <[email protected]>
  • Loading branch information
antazoey and fubuloubu authored Apr 12, 2022
1 parent f0c50b2 commit 990e1de
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 57 deletions.
56 changes: 34 additions & 22 deletions src/ape_accounts/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -46,6 +47,7 @@ class KeyfileAccount(AccountAPI):

keyfile_path: Path
locked: bool = True
__autosign: bool = False
__cached_key: Optional[HexBytes] = None

def __repr__(self):
Expand All @@ -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?"):
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import shutil
from pathlib import Path
from tempfile import mkdtemp

Expand Down Expand Up @@ -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)
28 changes: 28 additions & 0 deletions tests/functional/test_accounts.py
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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)
41 changes: 6 additions & 35 deletions tests/integration/cli/test_accounts.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json
from pathlib import Path

import pytest
from eth_account import Account # type: ignore
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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()

0 comments on commit 990e1de

Please sign in to comment.