From ec8f458e010345c3632ab5a8cedd068c75efe2ae Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Mon, 17 Jun 2024 23:44:01 +0200 Subject: [PATCH 01/14] Add send_ether command --- pyproject.toml | 1 + requirements.txt | 1 + src/safe_cli/operators/safe_operator.py | 10 +++- src/safe_cli/safe_runner.py | 71 +++++++++++++++++++++++++ src/safe_cli/typer_validators.py | 34 ++++++++++++ 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/safe_cli/safe_runner.py create mode 100644 src/safe_cli/typer_validators.py diff --git a/pyproject.toml b/pyproject.toml index 883ab6e4..9ba77e34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ trezor = ["trezor==0.13.8"] [project.scripts] safe-cli = "safe_cli.main:main" safe-creator = "safe_cli.safe_creator:main" +safe-runner = "safe_cli.safe_runner:main" [project.urls] Download = "https://github.com/gnosis/safe-cli/releases" diff --git a/requirements.txt b/requirements.txt index 66d37a6e..ea0ebd9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ requests==2.32.3 safe-eth-py==6.0.0b30 tabulate==0.9.0 trezor==0.13.8 +typer==0.12.3 web3==6.19.0 diff --git a/src/safe_cli/operators/safe_operator.py b/src/safe_cli/operators/safe_operator.py index 05928756..cb7ab03f 100644 --- a/src/safe_cli/operators/safe_operator.py +++ b/src/safe_cli/operators/safe_operator.py @@ -145,8 +145,11 @@ class SafeOperator: executed_transactions: List[str] _safe_cli_info: Optional[SafeCliInfo] require_all_signatures: bool + batch_mode: bool - def __init__(self, address: ChecksumAddress, node_url: str): + def __init__( + self, address: ChecksumAddress, node_url: str, batch_mode: bool = False + ): self.address = address self.node_url = node_url self.ethereum_client = EthereumClient(self.node_url) @@ -177,6 +180,7 @@ def __init__(self, address: ChecksumAddress, node_url: str): True # Require all signatures to be present to send a tx ) self.hw_wallet_manager = get_hw_wallet_manager() + self.batch_mode = batch_mode # Disable prompt dialogs @cached_property def last_default_fallback_handler_address(self) -> ChecksumAddress: @@ -928,7 +932,9 @@ def execute_safe_transaction(self, safe_tx: SafeTx): else: call_result = safe_tx.call(self.hw_wallet_manager.sender.address) print_formatted_text(HTML(f"Result: {call_result}")) - if yes_or_no_question("Do you want to execute tx " + str(safe_tx)): + if self.batch_mode or yes_or_no_question( + "Do you want to execute tx " + str(safe_tx) + ): if self.default_sender: tx_hash, tx = safe_tx.execute( self.default_sender.key, eip1559_speed=TxSpeed.NORMAL diff --git a/src/safe_cli/safe_runner.py b/src/safe_cli/safe_runner.py new file mode 100644 index 00000000..dd19cfff --- /dev/null +++ b/src/safe_cli/safe_runner.py @@ -0,0 +1,71 @@ +from typing import Annotated, List + +import typer +from eth_typing import ChecksumAddress + +from safe_cli.operators import SafeOperator +from safe_cli.prompt_parser import safe_exception +from safe_cli.typer_validators import ( + check_ethereum_address, + check_private_keys, + parse_checksum_address, +) + +app = typer.Typer() + + +@app.command() +@safe_exception +def send_ether( + safe_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + to: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of destination.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + value: Annotated[int, typer.Argument(help="Amount to send.", show_default=False)], + private_key: Annotated[ + List[str], + typer.Option( + help="List of private keys of signers.", + rich_help_panel="Optional Arguments", + show_default=False, + callback=check_private_keys, + ), + ] = None, + safe_nonce: Annotated[ + int, + typer.Option( + help="Force nonce for tx_sender", + rich_help_panel="Optional Arguments", + show_default=False, + ), + ] = None, +): + safe_operator = SafeOperator(safe_address, node_url, batch_mode=True) + safe_operator.load_cli_owners(private_key) + safe_operator.send_ether(to, value, safe_nonce=safe_nonce) + + +@app.command() +def test(): + print("Test") + + +def main(): + app() diff --git a/src/safe_cli/typer_validators.py b/src/safe_cli/typer_validators.py new file mode 100644 index 00000000..717aea24 --- /dev/null +++ b/src/safe_cli/typer_validators.py @@ -0,0 +1,34 @@ +from binascii import Error +from typing import List + +import typer +from eth_account import Account +from eth_typing import ChecksumAddress +from web3 import Web3 + + +def check_ethereum_address(address: str) -> ChecksumAddress: + """ + Ethereum address validator + """ + if not Web3.is_checksum_address(address): + raise typer.BadParameter("Invalid ethereum address") + return ChecksumAddress(address) + + +def check_private_keys(private_keys: List[str]) -> List[str]: + """ + Private Keys validator + """ + if private_keys is None: + raise typer.BadParameter("At least one private key is required") + for private_key in private_keys: + try: + Account.from_key(private_key) + except (ValueError, Error): + raise typer.BadParameter(f"{private_key} is not a valid private key") + return private_keys + + +def parse_checksum_address(address: str) -> ChecksumAddress: + return ChecksumAddress(address) From 9cad12d292d8ef83f08b9e91fe592ffd82795c21 Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Tue, 18 Jun 2024 20:55:22 +0200 Subject: [PATCH 02/14] Add send_custom, send_erc20 and send_erc721 commands --- src/safe_cli/operators/safe_operator.py | 15 +- src/safe_cli/safe_runner.py | 206 ++++++++++++++++++++++- src/safe_cli/typer_validators.py | 25 ++- tests/test_safe_runner.py | 208 ++++++++++++++++++++++++ tests/test_typer_validators.py | 50 ++++++ 5 files changed, 489 insertions(+), 15 deletions(-) create mode 100644 tests/test_safe_runner.py create mode 100644 tests/test_typer_validators.py diff --git a/src/safe_cli/operators/safe_operator.py b/src/safe_cli/operators/safe_operator.py index cb7ab03f..dd5f8af6 100644 --- a/src/safe_cli/operators/safe_operator.py +++ b/src/safe_cli/operators/safe_operator.py @@ -145,10 +145,10 @@ class SafeOperator: executed_transactions: List[str] _safe_cli_info: Optional[SafeCliInfo] require_all_signatures: bool - batch_mode: bool + script_mode: bool def __init__( - self, address: ChecksumAddress, node_url: str, batch_mode: bool = False + self, address: ChecksumAddress, node_url: str, script_mode: bool = False ): self.address = address self.node_url = node_url @@ -180,7 +180,7 @@ def __init__( True # Require all signatures to be present to send a tx ) self.hw_wallet_manager = get_hw_wallet_manager() - self.batch_mode = batch_mode # Disable prompt dialogs + self.script_mode = script_mode # Disable prompt dialogs @cached_property def last_default_fallback_handler_address(self) -> ChecksumAddress: @@ -932,9 +932,7 @@ def execute_safe_transaction(self, safe_tx: SafeTx): else: call_result = safe_tx.call(self.hw_wallet_manager.sender.address) print_formatted_text(HTML(f"Result: {call_result}")) - if self.batch_mode or yes_or_no_question( - "Do you want to execute tx " + str(safe_tx) - ): + if self._is_confirmed_transaction_execution(safe_tx): if self.default_sender: tx_hash, tx = safe_tx.execute( self.default_sender.key, eip1559_speed=TxSpeed.NORMAL @@ -1099,6 +1097,11 @@ def _require_tx_service_mode(self): ) ) + def _is_confirmed_transaction_execution(self, safe_tx: SafeTx) -> bool: + return self.script_mode or yes_or_no_question( + "Do you want to execute tx " + str(safe_tx) + ) + def get_delegates(self): return self._require_tx_service_mode() diff --git a/src/safe_cli/safe_runner.py b/src/safe_cli/safe_runner.py index dd19cfff..6677feea 100644 --- a/src/safe_cli/safe_runner.py +++ b/src/safe_cli/safe_runner.py @@ -2,20 +2,30 @@ import typer from eth_typing import ChecksumAddress +from hexbytes import HexBytes +from safe_cli import VERSION +from safe_cli.argparse_validators import check_hex_str from safe_cli.operators import SafeOperator -from safe_cli.prompt_parser import safe_exception from safe_cli.typer_validators import ( check_ethereum_address, check_private_keys, parse_checksum_address, + parse_hex_str, ) app = typer.Typer() +def _build_safe_operator( + safe_address: ChecksumAddress, node_url: str, private_keys: List[str] +) -> SafeOperator: + safe_operator = SafeOperator(safe_address, node_url, script_mode=True) + safe_operator.load_cli_owners(private_keys) + return safe_operator + + @app.command() -@safe_exception def send_ether( safe_address: Annotated[ ChecksumAddress, @@ -38,7 +48,9 @@ def send_ether( show_default=False, ), ], - value: Annotated[int, typer.Argument(help="Amount to send.", show_default=False)], + value: Annotated[ + int, typer.Argument(help="Amount of ether in wei to send.", show_default=False) + ], private_key: Annotated[ List[str], typer.Option( @@ -57,14 +69,194 @@ def send_ether( ), ] = None, ): - safe_operator = SafeOperator(safe_address, node_url, batch_mode=True) - safe_operator.load_cli_owners(private_key) + safe_operator = _build_safe_operator(safe_address, node_url, private_key) safe_operator.send_ether(to, value, safe_nonce=safe_nonce) @app.command() -def test(): - print("Test") +def send_erc20( + safe_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + to: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of destination.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + token_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="Erc20 token address.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + amount: Annotated[ + int, + typer.Argument( + help="Amount of erc20 tokens in wei to send.", show_default=False + ), + ], + private_key: Annotated[ + List[str], + typer.Option( + help="List of private keys of signers.", + rich_help_panel="Optional Arguments", + show_default=False, + callback=check_private_keys, + ), + ] = None, + safe_nonce: Annotated[ + int, + typer.Option( + help="Force nonce for tx_sender", + rich_help_panel="Optional Arguments", + show_default=False, + ), + ] = None, +): + safe_operator = _build_safe_operator(safe_address, node_url, private_key) + safe_operator.send_erc20(to, token_address, amount, safe_nonce=safe_nonce) + + +@app.command() +def send_erc721( + safe_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + to: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of destination.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + token_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="Erc721 token address.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + token_id: Annotated[ + int, typer.Argument(help="Erc721 token id.", show_default=False) + ], + private_key: Annotated[ + List[str], + typer.Option( + help="List of private keys of signers.", + rich_help_panel="Optional Arguments", + show_default=False, + callback=check_private_keys, + ), + ] = None, + safe_nonce: Annotated[ + int, + typer.Option( + help="Force nonce for tx_sender", + rich_help_panel="Optional Arguments", + show_default=False, + ), + ] = None, +): + safe_operator = _build_safe_operator(safe_address, node_url, private_key) + safe_operator.send_erc721(to, token_address, token_id, safe_nonce=safe_nonce) + + +@app.command() +def send_custom( + safe_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + to: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of destination.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + value: Annotated[int, typer.Argument(help="Value to send.", show_default=False)], + data: Annotated[ + HexBytes, + typer.Argument( + help="HexBytes data to send.", + callback=check_hex_str, + parser=parse_hex_str, + show_default=False, + ), + ], + private_key: Annotated[ + List[str], + typer.Option( + help="List of private keys of signers.", + rich_help_panel="Optional Arguments", + show_default=False, + callback=check_private_keys, + ), + ] = None, + safe_nonce: Annotated[ + int, + typer.Option( + help="Force nonce for tx_sender", + rich_help_panel="Optional Arguments", + show_default=False, + ), + ] = None, + delegate: Annotated[ + bool, + typer.Option( + help="Use DELEGATE_CALL. By default use CALL", + rich_help_panel="Optional Arguments", + ), + ] = False, +): + safe_operator = _build_safe_operator(safe_address, node_url, private_key) + safe_operator.send_custom( + to, value, data, safe_nonce=safe_nonce, delegate_call=delegate + ) + + +@app.command() +def version(): + print(f"Safe Runner v{VERSION}") def main(): diff --git a/src/safe_cli/typer_validators.py b/src/safe_cli/typer_validators.py index 717aea24..beb13383 100644 --- a/src/safe_cli/typer_validators.py +++ b/src/safe_cli/typer_validators.py @@ -4,6 +4,7 @@ import typer from eth_account import Account from eth_typing import ChecksumAddress +from hexbytes import HexBytes from web3 import Web3 @@ -16,6 +17,13 @@ def check_ethereum_address(address: str) -> ChecksumAddress: return ChecksumAddress(address) +def parse_checksum_address(address: str) -> ChecksumAddress: + """ + ChecksumAddress parser from str + """ + return ChecksumAddress(address) + + def check_private_keys(private_keys: List[str]) -> List[str]: """ Private Keys validator @@ -30,5 +38,18 @@ def check_private_keys(private_keys: List[str]) -> List[str]: return private_keys -def parse_checksum_address(address: str) -> ChecksumAddress: - return ChecksumAddress(address) +def check_hex_str(hex_str: str) -> HexBytes: + """ + Hexadecimal string validator for Argparse + """ + try: + return HexBytes(hex_str) + except ValueError: + raise typer.BadParameter(f"{hex_str} is not a valid hexadecimal string") + + +def parse_hex_str(data: str) -> HexBytes: + """ + Hexadecimal string parser from str + """ + return HexBytes(data) diff --git a/tests/test_safe_runner.py b/tests/test_safe_runner.py new file mode 100644 index 00000000..d2bef5fb --- /dev/null +++ b/tests/test_safe_runner.py @@ -0,0 +1,208 @@ +import unittest + +from eth_account import Account +from eth_typing import HexStr +from typer.testing import CliRunner + +from safe_cli import VERSION +from safe_cli.operators.exceptions import NotEnoughEtherToSend, SenderRequiredException +from safe_cli.safe_runner import app + +from .safe_cli_test_case_mixin import SafeCliTestCaseMixin + +runner = CliRunner() + + +class TestSafeRunner(SafeCliTestCaseMixin, unittest.TestCase): + + def test_version(self): + result = runner.invoke(app, ["version"]) + self.assertEqual(result.exit_code, 0) + self.assertIn(f"Safe Runner v{VERSION}", result.stdout) + + def test_send_ether(self): + safe_operator = self.setup_operator() + safe_owner = Account.create() + safe_operator.add_owner(safe_owner.address, 1) + random_address = Account.create().address + + # Test exception with exit code 1 + result = runner.invoke( + app, + [ + "send-ether", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + "20", + "--private-key", + safe_owner.key.hex(), + ], + ) + exception, _, _ = result.exc_info + self.assertEqual(exception, NotEnoughEtherToSend) + self.assertEqual(result.exit_code, 1) + + # Test exit code 0 + self._send_eth_to(safe_owner.address, 1000000000000000000) + self._send_eth_to(safe_operator.safe.address, 1000000000000000000) + result = runner.invoke( + app, + [ + "send-ether", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + "20", + "--private-key", + safe_owner.key.hex(), + ], + ) + self.assertEqual(result.exit_code, 0) + + def test_send_erc20(self): + safe_operator = self.setup_operator() + safe_owner = Account.create() + safe_operator.add_owner(safe_owner.address, 1) + + random_address = Account.create().address + random_token_address = Account.create().address + + # Test exception with exit code 1 + result = runner.invoke( + app, + [ + "send-erc20", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + random_token_address, + "20", + "--private-key", + safe_owner.key.hex(), + ], + ) + exception, _, _ = result.exc_info + self.assertEqual(exception, SenderRequiredException) + self.assertEqual(result.exit_code, 1) + + # Test exit code 0. Add user as a default signer + self._send_eth_to(safe_owner.address, 1000000000000000000) + + result = runner.invoke( + app, + [ + "send-erc20", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + random_token_address, + "20", + "--private-key", + safe_owner.key.hex(), + ], + ) + self.assertEqual(result.exit_code, 0) + + def test_send_erc721(self): + safe_operator = self.setup_operator() + safe_owner = Account.create() + safe_operator.add_owner(safe_owner.address, 1) + + random_address = Account.create().address + random_token_address = Account.create().address + + # Test exception with exit code 1 + result = runner.invoke( + app, + [ + "send-erc721", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + random_token_address, + "1", + "--private-key", + safe_owner.key.hex(), + ], + ) + exception, _, _ = result.exc_info + self.assertEqual(exception, SenderRequiredException) + self.assertEqual(result.exit_code, 1) + + # Test exit code 0. Add user as a default signer + self._send_eth_to(safe_owner.address, 1000000000000000000) + + result = runner.invoke( + app, + [ + "send-erc721", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + random_token_address, + "1", + "--private-key", + safe_owner.key.hex(), + ], + ) + self.assertEqual(result.exit_code, 0) + + def test_send_custom(self): + safe_operator = self.setup_operator() + safe_owner = Account.create() + safe_operator.add_owner(safe_owner.address, 1) + + random_address = Account.create().address + data = HexStr( + "0xa9059cbb00000000000000000000000079500008b4ea3cc3ad391145dca8a11bc04962280000000000000000000000000000000000000000000000000de0b6b3a7640000" + ) + + # Test exception with exit code 1 + result = runner.invoke( + app, + [ + "send-custom", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + "0", + data, + "--private-key", + safe_owner.key.hex(), + ], + ) + exception, _, _ = result.exc_info + self.assertEqual(exception, SenderRequiredException) + self.assertEqual(result.exit_code, 1) + + # Test exit code 0. Add user as a default signer + self._send_eth_to(safe_owner.address, 1000000000000000000) + + result = runner.invoke( + app, + [ + "send-custom", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + "0", + data, + "--private-key", + safe_owner.key.hex(), + ], + ) + self.assertEqual(result.exit_code, 0) + + def _send_eth_to(self, address: str, value: int) -> None: + self.ethereum_client.send_eth_to( + self.ethereum_test_account.key, + address, + self.w3.eth.gas_price, + value, + gas=50000, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_typer_validators.py b/tests/test_typer_validators.py new file mode 100644 index 00000000..0601f314 --- /dev/null +++ b/tests/test_typer_validators.py @@ -0,0 +1,50 @@ +import unittest + +import typer +from eth_account import Account +from eth_typing import ChecksumAddress +from hexbytes import HexBytes + +from safe_cli.typer_validators import ( + check_ethereum_address, + check_hex_str, + check_private_keys, + parse_checksum_address, + parse_hex_str, +) + + +class TestTyperValidators(unittest.TestCase): + + def test_check_ethereum_address(self): + address = "0x4127839cdf4F73d9fC9a2C2861d8d1799e9DF40C" + self.assertEqual(check_ethereum_address(address), ChecksumAddress(address)) + + not_valid_address = "0x4127839CDf4F73d9fC9a2C2861d8d1799e9DF40C" + with self.assertRaises(typer.BadParameter): + check_ethereum_address(not_valid_address) + + def test_check_private_keys(self): + account = Account.create() + self.assertEqual(check_private_keys([account.key.hex()]), [account.key.hex()]) + + with self.assertRaises(typer.BadParameter): + check_private_keys(["Random"]) + check_private_keys([]) + + def test_check_hex_str(self): + self.assertEqual(check_hex_str("0x12"), HexBytes("0x12")) + + with self.assertRaises(typer.BadParameter): + check_hex_str("0x12x") + + def test_parse_checksum_address(self): + address = "0x4127839cdf4F73d9fC9a2C2861d8d1799e9DF40C" + self.assertEqual(parse_checksum_address(address), ChecksumAddress(address)) + + def test_parse_hex_str(self): + self.assertEqual(parse_hex_str("0x12"), HexBytes("0x12")) + + +if __name__ == "__main__": + unittest.main() From 21d58c673905df3a75a07454d6c20cc9be086ec5 Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Wed, 19 Jun 2024 19:20:29 +0200 Subject: [PATCH 03/14] Add tx_builder command --- src/safe_cli/operators/safe_operator.py | 5 + src/safe_cli/safe_runner.py | 60 +++++ src/safe_cli/tx_builder/__init__.py | 0 src/safe_cli/tx_builder/exceptions.py | 10 + .../tx_builder/tx_builder_file_decoder.py | 241 ++++++++++++++++++ tests/mocks/tx_builder/batch_txs.json | 59 +++++ tests/mocks/tx_builder/empty_txs.json | 14 + tests/mocks/tx_builder/single_tx.json | 40 +++ tests/test_safe_runner.py | 56 +++- 9 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 src/safe_cli/tx_builder/__init__.py create mode 100644 src/safe_cli/tx_builder/exceptions.py create mode 100644 src/safe_cli/tx_builder/tx_builder_file_decoder.py create mode 100644 tests/mocks/tx_builder/batch_txs.json create mode 100644 tests/mocks/tx_builder/empty_txs.json create mode 100644 tests/mocks/tx_builder/single_tx.json diff --git a/src/safe_cli/operators/safe_operator.py b/src/safe_cli/operators/safe_operator.py index dd5f8af6..3c256d20 100644 --- a/src/safe_cli/operators/safe_operator.py +++ b/src/safe_cli/operators/safe_operator.py @@ -53,6 +53,7 @@ NotEnoughEtherToSend, NotEnoughSignatures, SafeAlreadyUpdatedException, + SafeOperatorException, SafeVersionNotSupportedException, SameFallbackHandlerException, SameGuardException, @@ -991,6 +992,10 @@ def batch_safe_txs( try: multisend = MultiSend(ethereum_client=self.ethereum_client) except ValueError: + if self.script_mode: + raise SafeOperatorException( + "Multisend contract is not deployed on this network and it's required for batching txs" + ) multisend = None print_formatted_text( HTML( diff --git a/src/safe_cli/safe_runner.py b/src/safe_cli/safe_runner.py index 6677feea..181331d9 100644 --- a/src/safe_cli/safe_runner.py +++ b/src/safe_cli/safe_runner.py @@ -1,3 +1,5 @@ +import json +from pathlib import Path from typing import Annotated, List import typer @@ -7,6 +9,7 @@ from safe_cli import VERSION from safe_cli.argparse_validators import check_hex_str from safe_cli.operators import SafeOperator +from safe_cli.tx_builder.tx_builder_file_decoder import convert_to_proposed_transactions from safe_cli.typer_validators import ( check_ethereum_address, check_private_keys, @@ -254,6 +257,63 @@ def send_custom( ) +@app.command() +def tx_builder( + safe_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + file_path: Annotated[ + Path, + typer.Argument( + exists=True, + file_okay=True, + dir_okay=False, + writable=False, + readable=True, + resolve_path=True, + help="File path with tx_builder data.", + show_default=False, + ), + ], + private_key: Annotated[ + List[str], + typer.Option( + help="List of private keys of signers.", + rich_help_panel="Optional Arguments", + show_default=False, + callback=check_private_keys, + ), + ] = None, +): + safe_operator = _build_safe_operator(safe_address, node_url, private_key) + data = json.loads(file_path.read_text()) + safe_txs = [] + for tx in convert_to_proposed_transactions(data): + safe_txs.append( + safe_operator.prepare_safe_transaction(tx.to, tx.value, tx.data) + ) + + if len(safe_txs) == 0: + raise typer.BadParameter("No transactions found.") + + if len(safe_txs) == 1: + safe_operator.execute_safe_transaction(safe_txs[0]) + return + + multisend_tx = safe_operator.batch_safe_txs(safe_operator.get_nonce(), safe_txs) + if multisend_tx is not None: + safe_operator.execute_safe_transaction(multisend_tx) + + @app.command() def version(): print(f"Safe Runner v{VERSION}") diff --git a/src/safe_cli/tx_builder/__init__.py b/src/safe_cli/tx_builder/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/safe_cli/tx_builder/exceptions.py b/src/safe_cli/tx_builder/exceptions.py new file mode 100644 index 00000000..d7a305e6 --- /dev/null +++ b/src/safe_cli/tx_builder/exceptions.py @@ -0,0 +1,10 @@ +class SoliditySyntaxError(Exception): + pass + + +class TxBuilderEncodingError(Exception): + pass + + +class InvalidContratMethodError(Exception): + pass diff --git a/src/safe_cli/tx_builder/tx_builder_file_decoder.py b/src/safe_cli/tx_builder/tx_builder_file_decoder.py new file mode 100644 index 00000000..f234e15a --- /dev/null +++ b/src/safe_cli/tx_builder/tx_builder_file_decoder.py @@ -0,0 +1,241 @@ +import dataclasses +import json +import re +from typing import Any, Dict, List + +from eth_abi import encode as encode_abi +from hexbytes import HexBytes +from web3 import Web3 + +from safe_cli.tx_builder.exceptions import ( + InvalidContratMethodError, + SoliditySyntaxError, + TxBuilderEncodingError, +) + +NON_VALID_CONTRACT_METHODS = ["receive", "fallback"] + + +def encode_contract_method_to_hex_data( + contract_method, contract_fields_values +) -> HexBytes: + contract_method_name = contract_method.get("name") if contract_method else None + contract_fields = contract_method.get("inputs", []) if contract_method else [] + + is_valid_contract_method = ( + contract_method_name and contract_method_name not in NON_VALID_CONTRACT_METHODS + ) + + if is_valid_contract_method: + try: + method_types = [field["type"] for field in contract_fields] + encoding_types = parse_type_values(contract_fields) + values = [ + parse_input_value( + field["type"], contract_fields_values.get(field["name"], "") + ) + for field in contract_fields + ] + + function_signature = f"{contract_method_name}({','.join(method_types)})" + function_selector = Web3.keccak(text=function_signature)[:4] + encoded_parameters = encode_abi(encoding_types, values) + hex_encoded_data = HexBytes(function_selector + encoded_parameters) + return hex_encoded_data + except Exception as error: + raise TxBuilderEncodingError( + "Error encoding current form values to hex data:", error + ) + else: + raise InvalidContratMethodError( + f"Invalid contract method {contract_method_name}" + ) + + +def parse_boolean_value(value) -> bool: + if isinstance(value, str): + if value.strip().lower() in ["true", "1"]: + return True + + if value.strip().lower() in ["false", "0"]: + return False + + raise SoliditySyntaxError("Invalid Boolean value") + + return bool(value) + + +def parse_int_value(value) -> int: + trimmed_value = value.replace('"', "").replace("'", "").strip() + + if trimmed_value == "": + raise SoliditySyntaxError("invalid empty strings for integers") + + if not trimmed_value.isdigit() and bool( + re.fullmatch(r"0[xX][0-9a-fA-F]+|[0-9a-fA-F]+$", trimmed_value) + ): + return int(trimmed_value, 16) + + return int(trimmed_value) + + +def parse_string_to_array(value) -> List[Any]: + number_of_items = 0 + number_of_other_arrays = 0 + result = [] + value = value.strip()[1:-1] # remove the first "[" and the last "]" + + for char in value: + if char == "," and number_of_other_arrays == 0: + number_of_items += 1 + continue + + if char == "[": + number_of_other_arrays += 1 + elif char == "]": + number_of_other_arrays -= 1 + + if len(result) <= number_of_items: + result.append("") + + result[number_of_items] += char.strip() + + return result + + +def get_base_field_type(field_type) -> str: + base_field_type_regex = re.compile( + r"^([a-zA-Z0-9]*)(((\[\])|(\[[1-9]+[0-9]*\]))*)?$" + ) + match = base_field_type_regex.match(field_type) + base_field_type = match.group(1) + + if not base_field_type: + raise SoliditySyntaxError( + f"Unknown base field type {base_field_type} from {field_type}" + ) + + return base_field_type + + +def is_array(values) -> bool: + trimmed_value = values.strip() + + return trimmed_value.startswith("[") and trimmed_value.endswith("]") + + +def parse_array_of_values(values, field_type) -> List[Any]: + if not is_array(values): + raise SoliditySyntaxError("Invalid Array value") + + parsed_values = parse_string_to_array(values) + return [ + ( + parse_array_of_values(item_value, field_type) + if is_array(item_value) + else parse_input_value(get_base_field_type(field_type), item_value) + ) + for item_value in parsed_values + ] + + +def is_boolean_field_type(field_type) -> bool: + return field_type == "bool" + + +def is_int_field_type(field_type) -> bool: + return field_type.startswith("uint") or field_type.startswith("int") + + +def is_tuple_field_type(field_type) -> bool: + return field_type.startswith("tuple") + + +def is_bytes_field_type(field_type) -> bool: + return field_type.startswith("bytes") + + +def is_array_of_strings_field_type(field_type) -> bool: + return field_type.startswith("string[") + + +def is_array_field_type(field_type) -> bool: + pattern = re.compile(r"\[\d*\]$") + return bool(pattern.search(field_type)) + + +def is_multi_dimensional_array_field_type(field_type) -> bool: + return field_type.count("[") > 1 + + +def parse_type_values(contract_fields: List[Dict[str, Any]]) -> List[Any]: + types = [] + + for field in contract_fields: + if is_tuple_field_type(field["type"]): + component_types = ",".join( + component["type"] for component in field["components"] + ) + types.append(f"({component_types})") + else: + types.append(field["type"]) + + return types + + +def parse_input_value(field_type, value) -> Any: + trimmed_value = value.strip() if isinstance(value, str) else value + + if is_tuple_field_type(field_type): + return tuple(json.loads(trimmed_value)) + + if is_array_of_strings_field_type(field_type): + return json.loads(trimmed_value) + + if is_array_field_type(field_type) or is_multi_dimensional_array_field_type( + field_type + ): + return parse_array_of_values(trimmed_value, field_type) + + if is_boolean_field_type(field_type): + return parse_boolean_value(trimmed_value) + + if is_int_field_type(field_type): + return parse_int_value(trimmed_value) + + if is_bytes_field_type(field_type): + return HexBytes(trimmed_value) + + return trimmed_value + + +@dataclasses.dataclass +class SafeProposedTx: + id: int + to: str + value: int + data: str + + def __str__(self): + return f"id={self.id} to={self.to} value={self.value} data={self.data.hex()}" + + +def convert_to_proposed_transactions( + batch_file: Dict[str, Any] +) -> List[SafeProposedTx]: + proposed_transactions = [] + for index, transaction in enumerate(batch_file["transactions"]): + proposed_transactions.append( + SafeProposedTx( + id=index, + to=transaction.get("to"), + value=transaction.get("value"), + data=transaction.get("data") + or encode_contract_method_to_hex_data( + transaction.get("contractMethod"), + transaction.get("contractInputsValues"), + ).hex() + or "0x", + ) + ) + return proposed_transactions diff --git a/tests/mocks/tx_builder/batch_txs.json b/tests/mocks/tx_builder/batch_txs.json new file mode 100644 index 00000000..79728d0c --- /dev/null +++ b/tests/mocks/tx_builder/batch_txs.json @@ -0,0 +1,59 @@ +{ + "version":"1.0", + "chainId":"11155111", + "createdAt":1718723305452, + "meta":{ + "name":"Transactions Batch", + "description":"", + "txBuilderVersion":"1.16.5", + "createdFromSafeAddress":"0xFFFFFFFF964459F3C984682f78A4d30713174b2E", + "createdFromOwnerAddress":"", + "checksum":"0x69d3b5239a5bdb8933c300000000006aba85e6ce721acc51721268896a66b79f" + }, + "transactions":[ + { + "to":"0xd16d9C09d13E9Cf77615771eADC5d51a1Ae92a26", + "value":"0", + "data":null, + "contractMethod":{ + "inputs":[ + { + "internalType":"address", + "name":"spender", + "type":"address" + }, + { + "internalType":"uint256", + "name":"amount", + "type":"uint256" + } + ], + "name":"approve", + "payable":false + }, + "contractInputsValues":{ + "spender":"0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", + "amount":"10" + } + }, + { + "to":"0xb161ccb96b9b817F9bDf0048F212725128779DE9", + "value":"0", + "data":null, + "contractMethod":{ + "inputs":[ + { + "internalType":"uint96", + "name":"amount", + "type":"uint96" + } + ], + "name":"lock", + "payable":false + }, + "contractInputsValues":{ + "amount":"10" + } + } + ] + } \ No newline at end of file diff --git a/tests/mocks/tx_builder/empty_txs.json b/tests/mocks/tx_builder/empty_txs.json new file mode 100644 index 00000000..2b64c1dc --- /dev/null +++ b/tests/mocks/tx_builder/empty_txs.json @@ -0,0 +1,14 @@ +{ + "version":"1.0", + "chainId":"11155111", + "createdAt":1718723305452, + "meta":{ + "name":"Transactions Batch", + "description":"", + "txBuilderVersion":"1.16.5", + "createdFromSafeAddress":"0xFFFFFFFF964459F3C984682f78A4d30713174b2E", + "createdFromOwnerAddress":"", + "checksum":"0x69d3b5239a5bdb8933c300000000006aba85e6ce721acc51721268896a66b79f" + }, + "transactions":[] + } \ No newline at end of file diff --git a/tests/mocks/tx_builder/single_tx.json b/tests/mocks/tx_builder/single_tx.json new file mode 100644 index 00000000..21518e5d --- /dev/null +++ b/tests/mocks/tx_builder/single_tx.json @@ -0,0 +1,40 @@ +{ + "version":"1.0", + "chainId":"11155111", + "createdAt":1718723305452, + "meta":{ + "name":"Transactions Batch", + "description":"", + "txBuilderVersion":"1.16.5", + "createdFromSafeAddress":"0xFFFFFFFF964459F3C984682f78A4d30713174b2E", + "createdFromOwnerAddress":"", + "checksum":"0x69d3b5239a5bdb8933c300000000006aba85e6ce721acc51721268896a66b79f" + }, + "transactions":[ + { + "to":"0xd16d9C09d13E9Cf77615771eADC5d51a1Ae92a26", + "value":"0", + "data":null, + "contractMethod":{ + "inputs":[ + { + "internalType":"address", + "name":"spender", + "type":"address" + }, + { + "internalType":"uint256", + "name":"amount", + "type":"uint256" + } + ], + "name":"approve", + "payable":false + }, + "contractInputsValues":{ + "spender":"0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", + "amount":"10" + } + } + ] + } \ No newline at end of file diff --git a/tests/test_safe_runner.py b/tests/test_safe_runner.py index d2bef5fb..bb81b710 100644 --- a/tests/test_safe_runner.py +++ b/tests/test_safe_runner.py @@ -5,7 +5,11 @@ from typer.testing import CliRunner from safe_cli import VERSION -from safe_cli.operators.exceptions import NotEnoughEtherToSend, SenderRequiredException +from safe_cli.operators.exceptions import ( + NotEnoughEtherToSend, + SafeOperatorException, + SenderRequiredException, +) from safe_cli.safe_runner import app from .safe_cli_test_case_mixin import SafeCliTestCaseMixin @@ -194,6 +198,56 @@ def test_send_custom(self): ) self.assertEqual(result.exit_code, 0) + def test_tx_builder(self): + safe_operator = self.setup_operator() + safe_owner = Account.create() + safe_operator.add_owner(safe_owner.address, 1) + self._send_eth_to(safe_owner.address, 1000000000000000000) + + # Test exit code 1 with empty file + result = runner.invoke( + app, + [ + "tx-builder", + safe_operator.safe.address, + "http://localhost:8545", + "tests/mocks/tx_builder/empty_txs.json", + "--private-key", + safe_owner.key.hex(), + ], + ) + self.assertEqual(result.exit_code, 2) + + # Test single tx exit 0 + result = runner.invoke( + app, + [ + "tx-builder", + safe_operator.safe.address, + "http://localhost:8545", + "tests/mocks/tx_builder/single_tx.json", + "--private-key", + safe_owner.key.hex(), + ], + ) + self.assertEqual(result.exit_code, 0) + + # Test batch txs (Ends with exception because the multisend contract is not deployed.) + result = runner.invoke( + app, + [ + "tx-builder", + safe_operator.safe.address, + "http://localhost:8545", + "tests/mocks/tx_builder/batch_txs.json", + "--private-key", + safe_owner.key.hex(), + ], + ) + exception, _, _ = result.exc_info + self.assertEqual(exception, SafeOperatorException) + self.assertEqual(result.exit_code, 1) + def _send_eth_to(self, address: str, value: int) -> None: self.ethereum_client.send_eth_to( self.ethereum_test_account.key, From c48fefc72cede079bb4706914497b0e34f0be0c0 Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Wed, 19 Jun 2024 19:40:33 +0200 Subject: [PATCH 04/14] Add support for env keys --- src/safe_cli/operators/safe_operator.py | 2 ++ .../tx_builder/tx_builder_file_decoder.py | 2 +- src/safe_cli/typer_validators.py | 5 +++-- tests/test_safe_runner.py | 17 +++++++++++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/safe_cli/operators/safe_operator.py b/src/safe_cli/operators/safe_operator.py index 3c256d20..27d9e8b3 100644 --- a/src/safe_cli/operators/safe_operator.py +++ b/src/safe_cli/operators/safe_operator.py @@ -284,6 +284,8 @@ def load_cli_owners(self, keys: List[str]): ) self.default_sender = account except ValueError: + if self.script_mode: + raise SafeOperatorException(f"Cannot load key={key}") print_formatted_text(HTML(f"Cannot load key={key}")) def load_hw_wallet( diff --git a/src/safe_cli/tx_builder/tx_builder_file_decoder.py b/src/safe_cli/tx_builder/tx_builder_file_decoder.py index f234e15a..0757c90e 100644 --- a/src/safe_cli/tx_builder/tx_builder_file_decoder.py +++ b/src/safe_cli/tx_builder/tx_builder_file_decoder.py @@ -69,7 +69,7 @@ def parse_int_value(value) -> int: trimmed_value = value.replace('"', "").replace("'", "").strip() if trimmed_value == "": - raise SoliditySyntaxError("invalid empty strings for integers") + raise SoliditySyntaxError("Invalid empty strings for integers") if not trimmed_value.isdigit() and bool( re.fullmatch(r"0[xX][0-9a-fA-F]+|[0-9a-fA-F]+$", trimmed_value) diff --git a/src/safe_cli/typer_validators.py b/src/safe_cli/typer_validators.py index beb13383..10c043bd 100644 --- a/src/safe_cli/typer_validators.py +++ b/src/safe_cli/typer_validators.py @@ -1,3 +1,4 @@ +import os from binascii import Error from typing import List @@ -32,7 +33,7 @@ def check_private_keys(private_keys: List[str]) -> List[str]: raise typer.BadParameter("At least one private key is required") for private_key in private_keys: try: - Account.from_key(private_key) + Account.from_key(os.environ.get(private_key, default=private_key)) except (ValueError, Error): raise typer.BadParameter(f"{private_key} is not a valid private key") return private_keys @@ -40,7 +41,7 @@ def check_private_keys(private_keys: List[str]) -> List[str]: def check_hex_str(hex_str: str) -> HexBytes: """ - Hexadecimal string validator for Argparse + Hexadecimal string validator """ try: return HexBytes(hex_str) diff --git a/tests/test_safe_runner.py b/tests/test_safe_runner.py index bb81b710..7df4b5c4 100644 --- a/tests/test_safe_runner.py +++ b/tests/test_safe_runner.py @@ -1,3 +1,4 @@ +import os import unittest from eth_account import Account @@ -64,6 +65,22 @@ def test_send_ether(self): ) self.assertEqual(result.exit_code, 0) + # Test key from env + os.environ["random_key"] = safe_owner.key.hex() + result = runner.invoke( + app, + [ + "send-ether", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + "20", + "--private-key", + "random_key", + ], + ) + self.assertEqual(result.exit_code, 0) + def test_send_erc20(self): safe_operator = self.setup_operator() safe_owner = Account.create() From 4071ebeb09aa0eb2a6a57fda5e89d9df20624791 Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Thu, 20 Jun 2024 12:07:41 +0200 Subject: [PATCH 05/14] Add test to tx_builder_decoder --- .../tx_builder/tx_builder_file_decoder.py | 102 +++---- tests/test_tx_builder_file_decoder.py | 257 ++++++++++++++++++ 2 files changed, 308 insertions(+), 51 deletions(-) create mode 100644 tests/test_tx_builder_file_decoder.py diff --git a/src/safe_cli/tx_builder/tx_builder_file_decoder.py b/src/safe_cli/tx_builder/tx_builder_file_decoder.py index 0757c90e..1be46986 100644 --- a/src/safe_cli/tx_builder/tx_builder_file_decoder.py +++ b/src/safe_cli/tx_builder/tx_builder_file_decoder.py @@ -16,8 +16,23 @@ NON_VALID_CONTRACT_METHODS = ["receive", "fallback"] +def _parse_types_to_encoding_types(contract_fields: List[Dict[str, Any]]) -> List[Any]: + types = [] + + for field in contract_fields: + if is_tuple_field_type(field["type"]): + component_types = ",".join( + component["type"] for component in field["components"] + ) + types.append(f"({component_types})") + else: + types.append(field["type"]) + + return types + + def encode_contract_method_to_hex_data( - contract_method, contract_fields_values + contract_method: Dict[str, Any], contract_fields_values: Dict[str, Any] ) -> HexBytes: contract_method_name = contract_method.get("name") if contract_method else None contract_fields = contract_method.get("inputs", []) if contract_method else [] @@ -29,7 +44,7 @@ def encode_contract_method_to_hex_data( if is_valid_contract_method: try: method_types = [field["type"] for field in contract_fields] - encoding_types = parse_type_values(contract_fields) + encoding_types = _parse_types_to_encoding_types(contract_fields) values = [ parse_input_value( field["type"], contract_fields_values.get(field["name"], "") @@ -52,7 +67,7 @@ def encode_contract_method_to_hex_data( ) -def parse_boolean_value(value) -> bool: +def parse_boolean_value(value: str) -> bool: if isinstance(value, str): if value.strip().lower() in ["true", "1"]: return True @@ -65,21 +80,23 @@ def parse_boolean_value(value) -> bool: return bool(value) -def parse_int_value(value) -> int: +def parse_int_value(value: str) -> int: trimmed_value = value.replace('"', "").replace("'", "").strip() if trimmed_value == "": raise SoliditySyntaxError("Invalid empty strings for integers") + try: + if not trimmed_value.isdigit() and bool( + re.fullmatch(r"0[xX][0-9a-fA-F]+|[0-9a-fA-F]+$", trimmed_value) + ): + return int(trimmed_value, 16) - if not trimmed_value.isdigit() and bool( - re.fullmatch(r"0[xX][0-9a-fA-F]+|[0-9a-fA-F]+$", trimmed_value) - ): - return int(trimmed_value, 16) - - return int(trimmed_value) + return int(trimmed_value) + except ValueError: + raise SoliditySyntaxError("Invalid integer value") -def parse_string_to_array(value) -> List[Any]: +def parse_string_to_array(value: str) -> List[Any]: number_of_items = 0 number_of_other_arrays = 0 result = [] @@ -103,87 +120,70 @@ def parse_string_to_array(value) -> List[Any]: return result -def get_base_field_type(field_type) -> str: +def _get_base_field_type(field_type: str) -> str: + trimmed_value = field_type.strip() + if not trimmed_value: + raise SoliditySyntaxError("Empty base field type for") + base_field_type_regex = re.compile( r"^([a-zA-Z0-9]*)(((\[\])|(\[[1-9]+[0-9]*\]))*)?$" ) - match = base_field_type_regex.match(field_type) - base_field_type = match.group(1) + match = base_field_type_regex.match(trimmed_value) + if not match: + raise SoliditySyntaxError(f"Unknown base field type from {trimmed_value}") + return match.group(1) - if not base_field_type: - raise SoliditySyntaxError( - f"Unknown base field type {base_field_type} from {field_type}" - ) - return base_field_type - - -def is_array(values) -> bool: +def _is_array(values: str) -> bool: trimmed_value = values.strip() - return trimmed_value.startswith("[") and trimmed_value.endswith("]") -def parse_array_of_values(values, field_type) -> List[Any]: - if not is_array(values): +def parse_array_of_values(values: str, field_type: str) -> List[Any]: + if not _is_array(values): raise SoliditySyntaxError("Invalid Array value") parsed_values = parse_string_to_array(values) return [ ( parse_array_of_values(item_value, field_type) - if is_array(item_value) - else parse_input_value(get_base_field_type(field_type), item_value) + if _is_array(item_value) + else parse_input_value(_get_base_field_type(field_type), item_value) ) for item_value in parsed_values ] -def is_boolean_field_type(field_type) -> bool: +def is_boolean_field_type(field_type: str) -> bool: return field_type == "bool" -def is_int_field_type(field_type) -> bool: +def is_int_field_type(field_type: str) -> bool: return field_type.startswith("uint") or field_type.startswith("int") -def is_tuple_field_type(field_type) -> bool: +def is_tuple_field_type(field_type: str) -> bool: return field_type.startswith("tuple") -def is_bytes_field_type(field_type) -> bool: +def is_bytes_field_type(field_type: str) -> bool: return field_type.startswith("bytes") -def is_array_of_strings_field_type(field_type) -> bool: +def is_array_of_strings_field_type(field_type: str) -> bool: return field_type.startswith("string[") -def is_array_field_type(field_type) -> bool: +def is_array_field_type(field_type: str) -> bool: pattern = re.compile(r"\[\d*\]$") return bool(pattern.search(field_type)) -def is_multi_dimensional_array_field_type(field_type) -> bool: +def is_multi_dimensional_array_field_type(field_type: str) -> bool: return field_type.count("[") > 1 -def parse_type_values(contract_fields: List[Dict[str, Any]]) -> List[Any]: - types = [] - - for field in contract_fields: - if is_tuple_field_type(field["type"]): - component_types = ",".join( - component["type"] for component in field["components"] - ) - types.append(f"({component_types})") - else: - types.append(field["type"]) - - return types - - -def parse_input_value(field_type, value) -> Any: +def parse_input_value(field_type: str, value: str) -> Any: trimmed_value = value.strip() if isinstance(value, str) else value if is_tuple_field_type(field_type): @@ -217,7 +217,7 @@ class SafeProposedTx: data: str def __str__(self): - return f"id={self.id} to={self.to} value={self.value} data={self.data.hex()}" + return f"id={self.id} to={self.to} value={self.value} data={self.data}" def convert_to_proposed_transactions( diff --git a/tests/test_tx_builder_file_decoder.py b/tests/test_tx_builder_file_decoder.py new file mode 100644 index 00000000..f6007783 --- /dev/null +++ b/tests/test_tx_builder_file_decoder.py @@ -0,0 +1,257 @@ +import unittest + +from eth_abi import encode as encode_abi +from hexbytes import HexBytes +from web3 import Web3 + +from safe_cli.tx_builder.exceptions import ( + InvalidContratMethodError, + SoliditySyntaxError, + TxBuilderEncodingError, +) +from safe_cli.tx_builder.tx_builder_file_decoder import ( + SafeProposedTx, + _get_base_field_type, + convert_to_proposed_transactions, + encode_contract_method_to_hex_data, + parse_array_of_values, + parse_boolean_value, + parse_input_value, + parse_int_value, + parse_string_to_array, +) + +from .safe_cli_test_case_mixin import SafeCliTestCaseMixin + + +class TestTxBuilderFileDecoder(SafeCliTestCaseMixin, unittest.TestCase): + def test_parse_boolean_value(self): + self.assertTrue(parse_boolean_value("true")) + self.assertTrue(parse_boolean_value(" TRUE ")) + self.assertTrue(parse_boolean_value("1")) + self.assertTrue(parse_boolean_value(" 1 ")) + self.assertFalse(parse_boolean_value("false")) + self.assertFalse(parse_boolean_value(" FALSE ")) + self.assertFalse(parse_boolean_value("0")) + self.assertFalse(parse_boolean_value(" 0 ")) + with self.assertRaises(SoliditySyntaxError): + parse_boolean_value("notabool") + self.assertTrue(parse_boolean_value(True)) + self.assertFalse(parse_boolean_value(False)) + self.assertTrue(parse_boolean_value(1)) + self.assertFalse(parse_boolean_value(0)) + + def test_parse_int_value(self): + self.assertEqual(parse_int_value("123"), 123) + self.assertEqual(parse_int_value("'789'"), 789) + self.assertEqual(parse_int_value('" 101112 "'), 101112) + self.assertEqual(parse_int_value("0x1A"), 26) + self.assertEqual(parse_int_value("0X1a"), 26) + self.assertEqual(parse_int_value(" 0x123 "), 291) + with self.assertRaises(SoliditySyntaxError): + parse_int_value(" ") + with self.assertRaises(SoliditySyntaxError): + parse_int_value("0x1G") + + def test_parse_string_to_array(self): + self.assertEqual(parse_string_to_array("[a,b,c]"), ["a", "b", "c"]) + self.assertEqual(parse_string_to_array("[1,2,3]"), ["1", "2", "3"]) + self.assertEqual(parse_string_to_array("[hello,world]"), ["hello", "world"]) + self.assertEqual(parse_string_to_array("[[a,b],[c,d]]"), ["[a,b]", "[c,d]"]) + self.assertEqual(parse_string_to_array("[ a , b , c ]"), ["a", "b", "c"]) + self.assertEqual( + parse_string_to_array('["[hello,world]","[foo,bar]"]'), + ['"[hello,world]"', '"[foo,bar]"'], + ) + + def test_get_base_field_type(self): + self.assertEqual(_get_base_field_type("uint"), "uint") + self.assertEqual(_get_base_field_type("int"), "int") + self.assertEqual(_get_base_field_type("address"), "address") + self.assertEqual(_get_base_field_type("bool"), "bool") + self.assertEqual(_get_base_field_type("string"), "string") + self.assertEqual(_get_base_field_type("uint[]"), "uint") + self.assertEqual(_get_base_field_type("int[10]"), "int") + self.assertEqual(_get_base_field_type("address[5][]"), "address") + self.assertEqual(_get_base_field_type("bool[][]"), "bool") + self.assertEqual(_get_base_field_type("string[3][4]"), "string") + self.assertEqual(_get_base_field_type("uint256"), "uint256") + self.assertEqual(_get_base_field_type("myCustomType[10][]"), "myCustomType") + with self.assertRaises(SoliditySyntaxError): + _get_base_field_type("[int]") + with self.assertRaises(SoliditySyntaxError): + _get_base_field_type("") + + def test_parse_array_of_values(self): + self.assertEqual(parse_array_of_values("[1,2,3]", "uint[]"), [1, 2, 3]) + self.assertEqual( + parse_array_of_values("[true,false,true]", "bool[]"), [True, False, True] + ) + self.assertEqual( + parse_array_of_values('["hello","world"]', "string[]"), + ['"hello"', '"world"'], + ) + self.assertEqual( + parse_array_of_values("[hello,world]", "string[]"), ["hello", "world"] + ) + self.assertEqual( + parse_array_of_values("[[1,2],[3,4]]", "uint[][]"), [[1, 2], [3, 4]] + ) + self.assertEqual( + parse_array_of_values("[[true,false],[false,true]]", "bool[][]"), + [[True, False], [False, True]], + ) + self.assertEqual( + parse_array_of_values('[["hello","world"],["foo","bar"]]', "string[][]"), + [['"hello"', '"world"'], ['"foo"', '"bar"']], + ) + self.assertEqual( + parse_array_of_values("[0x123, 0x456]", "address[]"), ["0x123", "0x456"] + ) + self.assertEqual( + parse_array_of_values("[[0x123], [0x456]]", "address[][]"), + [["0x123"], ["0x456"]], + ) + with self.assertRaises(SoliditySyntaxError): + parse_array_of_values("1,2,3", "uint[]") + + def test_parse_input_value(self): + self.assertEqual(parse_input_value("tuple", "[1,2,3]"), (1, 2, 3)) + self.assertEqual( + parse_input_value("string[]", '["a", "b", "c"]'), ["a", "b", "c"] + ) + self.assertEqual(parse_input_value("uint[]", "[1, 2, 3]"), [1, 2, 3]) + self.assertEqual( + parse_input_value("uint[2][2]", "[[1, 2], [3, 4]]"), [[1, 2], [3, 4]] + ) + self.assertTrue(parse_input_value("bool", "true")) + self.assertEqual(parse_input_value("int", "123"), 123) + self.assertEqual(parse_input_value("bytes", "0x1234"), HexBytes("0x1234")) + + def test_encode_contract_method_to_hex_data(self): + contract_method = { + "name": "transfer", + "inputs": [ + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + ], + } + contract_fields_values = { + "to": "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", + "value": "1000", + } + expected_hex = HexBytes( + Web3.keccak(text="transfer(address,uint256)")[:4] + + encode_abi( + ["address", "uint256"], + ["0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", 1000], + ) + ) + self.assertEqual( + encode_contract_method_to_hex_data(contract_method, contract_fields_values), + expected_hex, + ) + + # Test tuple + contract_method = { + "name": "transfer", + "inputs": [ + {"name": "to", "type": "address"}, + { + "components": [ + {"name": "name", "type": "string"}, + {"name": "age", "type": "uint8"}, + {"name": "userAddress", "type": "address"}, + {"name": "isNice", "type": "bool"}, + ], + "name": "contractOwnerNewValue", + "type": "tuple", + }, + ], + } + contract_fields_values = { + "to": "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", + "contractOwnerNewValue": '["hola",12,"0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5",true]', + } + expected_hex = HexBytes( + Web3.keccak(text="transfer(address,tuple)")[:4] + + encode_abi( + ["address", "(string,uint8,address,bool)"], + [ + "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", + ("hola", 12, "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", True), + ], + ) + ) + self.assertEqual( + encode_contract_method_to_hex_data(contract_method, contract_fields_values), + expected_hex, + ) + + # Test invalid contrat method + contract_method = {"name": "receive", "inputs": []} + contract_fields_values = {} + with self.assertRaises(InvalidContratMethodError): + encode_contract_method_to_hex_data(contract_method, contract_fields_values) + + # Test invalid value + contract_method = { + "name": "transfer", + "inputs": [ + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + ], + } + contract_fields_values = {"to": "0xRecipientAddress", "value": "invalidValue"} + with self.assertRaises(TxBuilderEncodingError): + encode_contract_method_to_hex_data(contract_method, contract_fields_values) + + def test_safe_proposed_tx_str(self): + tx = SafeProposedTx(id=1, to="0xRecipientAddress", value=1000, data="0x1234") + self.assertEqual(str(tx), "id=1 to=0xRecipientAddress value=1000 data=0x1234") + + def test_convert_to_proposed_transactions(self): + batch_file = { + "transactions": [ + { + "to": "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", + "value": 1000, + "data": "0x1234", + }, + { + "to": "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", + "value": 2000, + "contractMethod": { + "name": "transfer", + "inputs": [ + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + ], + }, + "contractInputsValues": { + "to": "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", + "value": "1000", + }, + }, + ] + } + expected = [ + SafeProposedTx( + id=0, + to="0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", + value=1000, + data="0x1234", + ), + SafeProposedTx( + id=1, + to="0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", + value=2000, + data="0xa9059cbb00000000000000000000000021c98f24acc673b9e1ad2c4191324701576cc2e500000000000000000000000000000000000000000000000000000000000003e8", + ), + ] + result = convert_to_proposed_transactions(batch_file) + self.assertEqual(result, expected) + + +if __name__ == "__main__": + unittest.main() From 59f335d48828a010952c606442cbcd95facd992a Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Thu, 20 Jun 2024 13:19:27 +0200 Subject: [PATCH 06/14] Update Readme --- README.md | 91 ++++++++++++++++++++++++++++++++++--- src/safe_cli/safe_runner.py | 1 + 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a9326a83..7c884052 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ You can also run the following command to run the Safe CLI with an existing Safe docker run -it safeglobal/safe-cli safe-cli ``` +To execute transactions unattended, or execute transactions from a json exported from the tx_builder you can use: +```bash +docker run -it safeglobal/safe-cli safe-runner +``` + ## Using Python PIP **Prerequisite:** [Python](https://www.python.org/downloads/) >= 3.9 (Python 3.12 is recommended). @@ -39,19 +44,87 @@ pip3 install -U safe-cli ## Usage +### Safe-Cli + ```bash -safe-cli [-h] [--history] [--get-safes-from-owner] address node_url +usage: + safe-cli [-h] [--history] [--get-safes-from-owner] address node_url + + Examples: + safe-cli 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org + safe-cli --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org + + safe-cli --history 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org + safe-cli --history --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org + + +positional arguments: + address The address of the Safe, or an owner address if --get-safes-from-owner is specified. + node_url Ethereum node url + +options: + -h, --help show this help message and exit + -v, --version Show program's version number and exit. + --history Enable history. By default it's disabled due to security reasons + --get-safes-from-owner + Indicates that address is an owner (Safe Transaction Service is required for this feature) + +``` + +### Safe-Creator + +```bash + +usage: + safe-creator [-h] [-v] [--threshold THRESHOLD] [--owners OWNERS [OWNERS ...]] [--safe-contract SAFE_CONTRACT] [--proxy-factory PROXY_FACTORY] [--callback-handler CALLBACK_HANDLER] [--salt-nonce SALT_NONCE] [--without-events] node_url private_key + + Example: + safe-creator https://sepolia.drpc.org 0000000000000000000000000000000000000000000000000000000000000000 + positional arguments: - address The address of the Safe, or an owner address if --get-safes-from-owner is specified. - node_url Ethereum node url + node_url Ethereum node url + private_key Deployer private_key options: - -h, --help Show this help message and exit - --history Enable history. By default it's disabled due to security reasons - --get-safes-from-owner Indicates that address is an owner (Safe Transaction Service is required for this feature) + -h, --help show this help message and exit + -v, --version Show program's version number and exit. + --threshold THRESHOLD + Number of owners required to execute transactions on the created Safe. It mustbe greater than 0 and less or equal than the number of owners + --owners OWNERS [OWNERS ...] + Owners. By default it will be just the deployer + --safe-contract SAFE_CONTRACT + Use a custom Safe master copy + --proxy-factory PROXY_FACTORY + Use a custom proxy factory + --callback-handler CALLBACK_HANDLER + Use a custom fallback handler. It is not required for Safe Master Copies with version < 1.1.0 + --salt-nonce SALT_NONCE + Use a custom nonce for the deployment. Same nonce with same deployment configuration will lead to the same Safe address + --without-events Use non events deployment of the Safe instead of the regular one. Recommended for mainnet to save gas costs when using the Safe + + ``` +### Safe-Runner + +```bash +safe-runner send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN +safe-runner send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN +safe-runner send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN +safe-runner send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN + +safe-runner tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN +``` +It is possible to get help for each command separately using: + +```bash +safe-runner command --help +``` +Or list the available commands +```bash +safe-runner --help +``` ## Safe{Core} API/Protocol @@ -72,6 +145,12 @@ source venv/bin/activate && pip install -r requirements-dev.txt pre-commit install -f ``` +To run the local version you can install it using: + +```bash +pip install . +``` + ## Contributors - [Pedro Arias Ruiz](https://github.com/AsiganTheSunk) diff --git a/src/safe_cli/safe_runner.py b/src/safe_cli/safe_runner.py index 181331d9..b50d7751 100644 --- a/src/safe_cli/safe_runner.py +++ b/src/safe_cli/safe_runner.py @@ -1,3 +1,4 @@ +#!/bin/env python3 import json from pathlib import Path from typing import Annotated, List From 6df8ef92ceb1803783ae2c1e8418c6e2d20f1f5f Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Thu, 20 Jun 2024 14:18:03 +0200 Subject: [PATCH 07/14] Fix encoding of tuple type --- src/safe_cli/tx_builder/tx_builder_file_decoder.py | 3 +-- tests/test_tx_builder_file_decoder.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/safe_cli/tx_builder/tx_builder_file_decoder.py b/src/safe_cli/tx_builder/tx_builder_file_decoder.py index 1be46986..857b7eeb 100644 --- a/src/safe_cli/tx_builder/tx_builder_file_decoder.py +++ b/src/safe_cli/tx_builder/tx_builder_file_decoder.py @@ -43,7 +43,6 @@ def encode_contract_method_to_hex_data( if is_valid_contract_method: try: - method_types = [field["type"] for field in contract_fields] encoding_types = _parse_types_to_encoding_types(contract_fields) values = [ parse_input_value( @@ -52,7 +51,7 @@ def encode_contract_method_to_hex_data( for field in contract_fields ] - function_signature = f"{contract_method_name}({','.join(method_types)})" + function_signature = f"{contract_method_name}({','.join(encoding_types)})" function_selector = Web3.keccak(text=function_signature)[:4] encoded_parameters = encode_abi(encoding_types, values) hex_encoded_data = HexBytes(function_selector + encoded_parameters) diff --git a/tests/test_tx_builder_file_decoder.py b/tests/test_tx_builder_file_decoder.py index f6007783..5475565c 100644 --- a/tests/test_tx_builder_file_decoder.py +++ b/tests/test_tx_builder_file_decoder.py @@ -152,7 +152,7 @@ def test_encode_contract_method_to_hex_data(self): expected_hex, ) - # Test tuple + # Test tuple type contract_method = { "name": "transfer", "inputs": [ @@ -174,7 +174,7 @@ def test_encode_contract_method_to_hex_data(self): "contractOwnerNewValue": '["hola",12,"0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5",true]', } expected_hex = HexBytes( - Web3.keccak(text="transfer(address,tuple)")[:4] + Web3.keccak(text="transfer(address,(string,uint8,address,bool))")[:4] + encode_abi( ["address", "(string,uint8,address,bool)"], [ From ee7a27ce584ceeeef94519bf6957aebe0d88c6a8 Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Sun, 23 Jun 2024 21:07:17 +0200 Subject: [PATCH 08/14] Merge safe-cli and safe-runner --- README.md | 89 +-- pyproject.toml | 1 - src/safe_cli/main.py | 548 ++++++++++++------ src/safe_cli/safe_cli.py | 128 ++++ src/safe_cli/safe_runner.py | 324 ----------- src/safe_cli/utils.py | 9 +- tests/test_entrypoint.py | 107 ---- ...runner.py => test_safe_cli_entry_point.py} | 113 +++- 8 files changed, 667 insertions(+), 652 deletions(-) create mode 100644 src/safe_cli/safe_cli.py delete mode 100644 src/safe_cli/safe_runner.py delete mode 100644 tests/test_entrypoint.py rename tests/{test_safe_runner.py => test_safe_cli_entry_point.py} (70%) diff --git a/README.md b/README.md index 7c884052..a87c422a 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,6 @@ You can also run the following command to run the Safe CLI with an existing Safe docker run -it safeglobal/safe-cli safe-cli ``` -To execute transactions unattended, or execute transactions from a json exported from the tx_builder you can use: -```bash -docker run -it safeglobal/safe-cli safe-runner -``` - ## Using Python PIP **Prerequisite:** [Python](https://www.python.org/downloads/) >= 3.9 (Python 3.12 is recommended). @@ -48,27 +43,55 @@ pip3 install -U safe-cli ```bash usage: - safe-cli [-h] [--history] [--get-safes-from-owner] address node_url - - Examples: - safe-cli 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org - safe-cli --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org - - safe-cli --history 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org - safe-cli --history --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org - + safe-cli [--history] [--get-safes-from-owner] address node_url + + Examples: + safe-cli 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org + safe-cli --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org + + safe-cli --history 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org + safe-cli --history --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org + + safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN + safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN + safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN + safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN + + safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN + +╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * address PARSE_CHECKSUM_ADDRESS The address of the Safe, or an owner address if --get-safes-from-owner is specified. [required] │ +│ * node_url TEXT Ethereum node url. [required] │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Optional Arguments ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --history --no-history Enable history. By default it's disabled due to security reasons [default: no-history] │ +│ --get-safes-from-owner --no-get-safes-from-owner Indicates that address is an owner (Safe Transaction Service is required for this feature) [default: no-get-safes-from-owner] │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Commands available in unattended mode: + + send-ether + send-erc20 + send-erc721 + send-custom + tx-builder + version + + Use the --help option of each command to see the usage options. +``` -positional arguments: - address The address of the Safe, or an owner address if --get-safes-from-owner is specified. - node_url Ethereum node url +To execute transactions unattended, or execute transactions from a json exported from the tx_builder you can use: -options: - -h, --help show this help message and exit - -v, --version Show program's version number and exit. - --history Enable history. By default it's disabled due to security reasons - --get-safes-from-owner - Indicates that address is an owner (Safe Transaction Service is required for this feature) +```bash +safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN +safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN +safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN +safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN +safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN ``` ### Safe-Creator @@ -106,26 +129,6 @@ options: ``` -### Safe-Runner - -```bash -safe-runner send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN -safe-runner send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN -safe-runner send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN -safe-runner send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN - -safe-runner tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN -``` -It is possible to get help for each command separately using: - -```bash -safe-runner command --help -``` -Or list the available commands -```bash -safe-runner --help -``` - ## Safe{Core} API/Protocol - [Safe Infrastructure](https://github.com/safe-global/safe-infrastructure) diff --git a/pyproject.toml b/pyproject.toml index 9ba77e34..883ab6e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ trezor = ["trezor==0.13.8"] [project.scripts] safe-cli = "safe_cli.main:main" safe-creator = "safe_cli.safe_creator:main" -safe-runner = "safe_cli.safe_runner:main" [project.urls] Download = "https://github.com/gnosis/safe-cli/releases" diff --git a/src/safe_cli/main.py b/src/safe_cli/main.py index 2ae2f04f..61cd05ea 100644 --- a/src/safe_cli/main.py +++ b/src/safe_cli/main.py @@ -1,193 +1,405 @@ #!/bin/env python3 -import argparse -import os +import json import sys -from typing import Optional +from pathlib import Path +from typing import Annotated, List -from art import text2art +import typer from eth_typing import ChecksumAddress -from prompt_toolkit import HTML, PromptSession, print_formatted_text -from prompt_toolkit.auto_suggest import AutoSuggestFromHistory -from prompt_toolkit.history import FileHistory -from prompt_toolkit.lexers import PygmentsLexer - -from safe_cli.argparse_validators import check_ethereum_address -from safe_cli.operators import ( - SafeCliTerminationException, - SafeOperator, - SafeServiceNotAvailable, - SafeTxServiceOperator, +from hexbytes import HexBytes +from typer.main import get_command, get_command_name + +from safe_cli import VERSION +from safe_cli.argparse_validators import check_hex_str +from safe_cli.operators import SafeOperator +from safe_cli.safe_cli import SafeCli +from safe_cli.tx_builder.tx_builder_file_decoder import convert_to_proposed_transactions +from safe_cli.typer_validators import ( + check_ethereum_address, + check_private_keys, + parse_checksum_address, + parse_hex_str, ) -from safe_cli.prompt_parser import PromptParser -from safe_cli.safe_completer import SafeCompleter -from safe_cli.safe_lexer import SafeLexer from safe_cli.utils import get_safe_from_owner -from . import VERSION - - -class SafeCli: - def __init__(self, safe_address: ChecksumAddress, node_url: str, history: bool): - """ - :param safe_address: Safe address - :param node_url: Ethereum RPC url - :param history: If `True` keep command history, otherwise history is not kept after closing the CLI - """ - self.safe_address = safe_address - self.node_url = node_url - if history: - self.session = PromptSession( - history=FileHistory(os.path.join(sys.path[0], ".history")) - ) - else: - self.session = PromptSession() - self.safe_operator = SafeOperator(safe_address, node_url) - self.prompt_parser = PromptParser(self.safe_operator) - - def print_startup_info(self): - print_formatted_text(text2art("Safe CLI")) # Print fancy text - print_formatted_text(HTML(f"Version: {VERSION}")) - print_formatted_text( - HTML("Loading Safe information...") - ) - self.safe_operator.print_info() +app = typer.Typer(name="Safe CLI") - print_formatted_text( - HTML("\nUse the tab key to show options in interactive mode.") - ) - print_formatted_text( - HTML( - "The help command displays all available options and the exit command terminates the safe-cli." - ) - ) - def get_prompt_text(self): - mode: Optional[str] = "blockchain" - if isinstance(self.prompt_parser.safe_operator, SafeTxServiceOperator): - mode = "tx-service" +def _build_safe_operator_and_load_keys( + safe_address: ChecksumAddress, node_url: str, private_keys: List[str] +) -> SafeOperator: + safe_operator = SafeOperator(safe_address, node_url, script_mode=True) + safe_operator.load_cli_owners(private_keys) + return safe_operator - return HTML( - f"{mode} > {self.safe_address} > " - ) - def get_bottom_toolbar(self): - return HTML( - f'" - ) +@app.command() +def send_ether( + safe_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + to: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of destination.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + value: Annotated[ + int, typer.Argument(help="Amount of ether in wei to send.", show_default=False) + ], + private_key: Annotated[ + List[str], + typer.Option( + help="List of private keys of signers.", + rich_help_panel="Optional Arguments", + show_default=False, + callback=check_private_keys, + ), + ] = None, + safe_nonce: Annotated[ + int, + typer.Option( + help="Force nonce for tx_sender", + rich_help_panel="Optional Arguments", + show_default=False, + ), + ] = None, +): + safe_operator = _build_safe_operator_and_load_keys( + safe_address, node_url, private_key + ) + safe_operator.send_ether(to, value, safe_nonce=safe_nonce) - def parse_operator_mode(self, command: str) -> Optional[SafeOperator]: - """ - Parse operator mode to switch between blockchain (default) and tx-service - :param command: - :return: SafeOperator if detected - """ - split_command = command.split() - try: - if (split_command[0]) == "tx-service": - print_formatted_text( - HTML("Sending txs to tx service") - ) - return SafeTxServiceOperator(self.safe_address, self.node_url) - elif split_command[0] == "blockchain": - print_formatted_text( - HTML("Sending txs to blockchain") - ) - return self.safe_operator - except SafeServiceNotAvailable: - print_formatted_text( - HTML("Mode not supported on this network") - ) - - def get_command(self) -> str: - return self.session.prompt( - self.get_prompt_text, - auto_suggest=AutoSuggestFromHistory(), - bottom_toolbar=self.get_bottom_toolbar, - lexer=PygmentsLexer(SafeLexer), - completer=SafeCompleter(), - ) - def loop(self): - while True: - try: - command = self.get_command() - if not command.strip(): - continue - - new_operator = self.parse_operator_mode(command) - if new_operator: - self.prompt_parser = PromptParser(new_operator) - new_operator.refresh_safe_cli_info() # ClI info needs to be initialized - else: - self.prompt_parser.process_command(command) - except SafeCliTerminationException: - break - except EOFError: - break - except KeyboardInterrupt: - continue - except (argparse.ArgumentError, argparse.ArgumentTypeError, SystemExit): - pass - - -def get_usage_msg(): - return """ - safe-cli [-h] [--history] [--get-safes-from-owner] address node_url - - Examples: - safe-cli 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org - safe-cli --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org - - safe-cli --history 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org - safe-cli --history --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org - """ - - -def build_safe_cli() -> Optional[SafeCli]: - parser = argparse.ArgumentParser(usage=get_usage_msg()) - parser.add_argument( - "-v", - "--version", - action="version", - version=f"Safe CLI v{VERSION}", - help="Show program's version number and exit.", +@app.command() +def send_erc20( + safe_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + to: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of destination.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + token_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="Erc20 token address.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + amount: Annotated[ + int, + typer.Argument( + help="Amount of erc20 tokens in wei to send.", show_default=False + ), + ], + private_key: Annotated[ + List[str], + typer.Option( + help="List of private keys of signers.", + rich_help_panel="Optional Arguments", + show_default=False, + callback=check_private_keys, + ), + ] = None, + safe_nonce: Annotated[ + int, + typer.Option( + help="Force nonce for tx_sender", + rich_help_panel="Optional Arguments", + show_default=False, + ), + ] = None, +): + safe_operator = _build_safe_operator_and_load_keys( + safe_address, node_url, private_key ) + safe_operator.send_erc20(to, token_address, amount, safe_nonce=safe_nonce) + - parser.add_argument( - "address", - help="The address of the Safe, or an owner address if --get-safes-from-owner is specified.", - type=check_ethereum_address, +@app.command() +def send_erc721( + safe_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + to: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of destination.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + token_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="Erc721 token address.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + token_id: Annotated[ + int, typer.Argument(help="Erc721 token id.", show_default=False) + ], + private_key: Annotated[ + List[str], + typer.Option( + help="List of private keys of signers.", + rich_help_panel="Optional Arguments", + show_default=False, + callback=check_private_keys, + ), + ] = None, + safe_nonce: Annotated[ + int, + typer.Option( + help="Force nonce for tx_sender", + rich_help_panel="Optional Arguments", + show_default=False, + ), + ] = None, +): + safe_operator = _build_safe_operator_and_load_keys( + safe_address, node_url, private_key ) - parser.add_argument("node_url", help="Ethereum node url") - parser.add_argument( - "--history", - action="store_true", - help="Enable history. By default it's disabled due to security reasons", - default=False, + safe_operator.send_erc721(to, token_address, token_id, safe_nonce=safe_nonce) + + +@app.command() +def send_custom( + safe_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + to: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of destination.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + value: Annotated[int, typer.Argument(help="Value to send.", show_default=False)], + data: Annotated[ + HexBytes, + typer.Argument( + help="HexBytes data to send.", + callback=check_hex_str, + parser=parse_hex_str, + show_default=False, + ), + ], + private_key: Annotated[ + List[str], + typer.Option( + help="List of private keys of signers.", + rich_help_panel="Optional Arguments", + show_default=False, + callback=check_private_keys, + ), + ] = None, + safe_nonce: Annotated[ + int, + typer.Option( + help="Force nonce for tx_sender", + rich_help_panel="Optional Arguments", + show_default=False, + ), + ] = None, + delegate: Annotated[ + bool, + typer.Option( + help="Use DELEGATE_CALL. By default use CALL", + rich_help_panel="Optional Arguments", + ), + ] = False, +): + safe_operator = _build_safe_operator_and_load_keys( + safe_address, node_url, private_key ) - parser.add_argument( - "--get-safes-from-owner", - action="store_true", - help="Indicates that address is an owner (Safe Transaction Service is required for this feature)", - default=False, + safe_operator.send_custom( + to, value, data, safe_nonce=safe_nonce, delegate_call=delegate ) - args = parser.parse_args() - if args.get_safes_from_owner: - if ( - safe_address := get_safe_from_owner(args.address, args.node_url) - ) is not None: - return SafeCli(safe_address, args.node_url, args.history) - else: - return SafeCli(args.address, args.node_url, args.history) + +@app.command() +def tx_builder( + safe_address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + file_path: Annotated[ + Path, + typer.Argument( + exists=True, + file_okay=True, + dir_okay=False, + writable=False, + readable=True, + resolve_path=True, + help="File path with tx_builder data.", + show_default=False, + ), + ], + private_key: Annotated[ + List[str], + typer.Option( + help="List of private keys of signers.", + rich_help_panel="Optional Arguments", + show_default=False, + callback=check_private_keys, + ), + ] = None, +): + safe_operator = _build_safe_operator_and_load_keys( + safe_address, node_url, private_key + ) + data = json.loads(file_path.read_text()) + safe_txs = [] + for tx in convert_to_proposed_transactions(data): + safe_txs.append( + safe_operator.prepare_safe_transaction(tx.to, tx.value, tx.data) + ) + + if len(safe_txs) == 0: + raise typer.BadParameter("No transactions found.") + + if len(safe_txs) == 1: + safe_operator.execute_safe_transaction(safe_txs[0]) + return + + multisend_tx = safe_operator.batch_safe_txs(safe_operator.get_nonce(), safe_txs) + if multisend_tx is not None: + safe_operator.execute_safe_transaction(multisend_tx) -def main(*args, **kwargs): - safe_cli = build_safe_cli() +@app.command() +def version(): + print(f"Safe Cli v{VERSION}") + + +@app.command( + hidden=True, + name="attended-mode", + help=""" + safe-cli [--history] [--get-safes-from-owner] address node_url\n + Examples:\n + safe-cli 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n + safe-cli --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n\n\n\n + safe-cli --history 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n + safe-cli --history --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n\n\n\n + safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN\n + safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN\n + safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN\n + safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN\n\n\n\n + safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN + """, + epilog="Commands available in unattended mode:\n\n\n\n" + + "\n\n".join( + [ + f" {get_command_name(command)}" + for command in get_command(app).commands.keys() + ] + ) + + "\n\n\n\nUse the --help option of each command to see the usage options.", +) +def default_attended_mode( + address: Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe, or an owner address if --get-safes-from-owner is specified.", + callback=check_ethereum_address, + parser=parse_checksum_address, + show_default=False, + ), + ], + node_url: Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) + ], + history: Annotated[ + bool, + typer.Option( + help="Enable history. By default it's disabled due to security reasons", + rich_help_panel="Optional Arguments", + ), + ] = False, + get_safes_from_owner: Annotated[ + bool, + typer.Option( + help="Indicates that address is an owner (Safe Transaction Service is required for this feature)", + rich_help_panel="Optional Arguments", + ), + ] = False, +) -> None: + if get_safes_from_owner: + safe_address_listed = get_safe_from_owner(address, node_url) + safe_cli = SafeCli(safe_address_listed, node_url, history) + else: + safe_cli = SafeCli(address, node_url, history) safe_cli.print_startup_info() safe_cli.loop() -if __name__ == "__main__": - main() +def main(): + # By default, the attended mode is initialised. Otherwise, the required command must be specified. + if len(sys.argv) == 1 or sys.argv[1] not in [ + get_command_name(key) for key in get_command(app).commands.keys() + ]: + sys.argv.insert(1, "attended-mode") + app() diff --git a/src/safe_cli/safe_cli.py b/src/safe_cli/safe_cli.py new file mode 100644 index 00000000..6bb069a8 --- /dev/null +++ b/src/safe_cli/safe_cli.py @@ -0,0 +1,128 @@ +import argparse +import os +import sys +from typing import Optional + +from art import text2art +from eth_typing import ChecksumAddress +from prompt_toolkit import HTML, PromptSession, print_formatted_text +from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from prompt_toolkit.history import FileHistory +from prompt_toolkit.lexers import PygmentsLexer + +from safe_cli.operators import ( + SafeCliTerminationException, + SafeOperator, + SafeServiceNotAvailable, + SafeTxServiceOperator, +) +from safe_cli.prompt_parser import PromptParser +from safe_cli.safe_completer import SafeCompleter +from safe_cli.safe_lexer import SafeLexer + +from . import VERSION + + +class SafeCli: + def __init__(self, safe_address: ChecksumAddress, node_url: str, history: bool): + """ + :param safe_address: Safe address + :param node_url: Ethereum RPC url + :param history: If `True` keep command history, otherwise history is not kept after closing the CLI + """ + self.safe_address = safe_address + self.node_url = node_url + if history: + self.session = PromptSession( + history=FileHistory(os.path.join(sys.path[0], ".history")) + ) + else: + self.session = PromptSession() + self.safe_operator = SafeOperator(safe_address, node_url) + self.prompt_parser = PromptParser(self.safe_operator) + + def print_startup_info(self): + print_formatted_text(text2art("Safe CLI")) # Print fancy text + print_formatted_text(HTML(f"Version: {VERSION}")) + print_formatted_text( + HTML("Loading Safe information...") + ) + self.safe_operator.print_info() + + print_formatted_text( + HTML("\nUse the tab key to show options in interactive mode.") + ) + print_formatted_text( + HTML( + "The help command displays all available options and the exit command terminates the safe-cli." + ) + ) + + def get_prompt_text(self): + mode: Optional[str] = "blockchain" + if isinstance(self.prompt_parser.safe_operator, SafeTxServiceOperator): + mode = "tx-service" + + return HTML( + f"{mode} > {self.safe_address} > " + ) + + def get_bottom_toolbar(self): + return HTML( + f'" + ) + + def parse_operator_mode(self, command: str) -> Optional[SafeOperator]: + """ + Parse operator mode to switch between blockchain (default) and tx-service + :param command: + :return: SafeOperator if detected + """ + split_command = command.split() + try: + if (split_command[0]) == "tx-service": + print_formatted_text( + HTML("Sending txs to tx service") + ) + return SafeTxServiceOperator(self.safe_address, self.node_url) + elif split_command[0] == "blockchain": + print_formatted_text( + HTML("Sending txs to blockchain") + ) + return self.safe_operator + except SafeServiceNotAvailable: + print_formatted_text( + HTML("Mode not supported on this network") + ) + + def get_command(self) -> str: + return self.session.prompt( + self.get_prompt_text, + auto_suggest=AutoSuggestFromHistory(), + bottom_toolbar=self.get_bottom_toolbar, + lexer=PygmentsLexer(SafeLexer), + completer=SafeCompleter(), + ) + + def loop(self): + while True: + try: + command = self.get_command() + if not command.strip(): + continue + + new_operator = self.parse_operator_mode(command) + if new_operator: + self.prompt_parser = PromptParser(new_operator) + new_operator.refresh_safe_cli_info() # ClI info needs to be initialized + else: + self.prompt_parser.process_command(command) + except SafeCliTerminationException: + break + except EOFError: + break + except KeyboardInterrupt: + continue + except (argparse.ArgumentError, argparse.ArgumentTypeError, SystemExit): + pass diff --git a/src/safe_cli/safe_runner.py b/src/safe_cli/safe_runner.py deleted file mode 100644 index b50d7751..00000000 --- a/src/safe_cli/safe_runner.py +++ /dev/null @@ -1,324 +0,0 @@ -#!/bin/env python3 -import json -from pathlib import Path -from typing import Annotated, List - -import typer -from eth_typing import ChecksumAddress -from hexbytes import HexBytes - -from safe_cli import VERSION -from safe_cli.argparse_validators import check_hex_str -from safe_cli.operators import SafeOperator -from safe_cli.tx_builder.tx_builder_file_decoder import convert_to_proposed_transactions -from safe_cli.typer_validators import ( - check_ethereum_address, - check_private_keys, - parse_checksum_address, - parse_hex_str, -) - -app = typer.Typer() - - -def _build_safe_operator( - safe_address: ChecksumAddress, node_url: str, private_keys: List[str] -) -> SafeOperator: - safe_operator = SafeOperator(safe_address, node_url, script_mode=True) - safe_operator.load_cli_owners(private_keys) - return safe_operator - - -@app.command() -def send_ether( - safe_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of the Safe.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], - to: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of destination.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - value: Annotated[ - int, typer.Argument(help="Amount of ether in wei to send.", show_default=False) - ], - private_key: Annotated[ - List[str], - typer.Option( - help="List of private keys of signers.", - rich_help_panel="Optional Arguments", - show_default=False, - callback=check_private_keys, - ), - ] = None, - safe_nonce: Annotated[ - int, - typer.Option( - help="Force nonce for tx_sender", - rich_help_panel="Optional Arguments", - show_default=False, - ), - ] = None, -): - safe_operator = _build_safe_operator(safe_address, node_url, private_key) - safe_operator.send_ether(to, value, safe_nonce=safe_nonce) - - -@app.command() -def send_erc20( - safe_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of the Safe.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], - to: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of destination.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - token_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="Erc20 token address.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - amount: Annotated[ - int, - typer.Argument( - help="Amount of erc20 tokens in wei to send.", show_default=False - ), - ], - private_key: Annotated[ - List[str], - typer.Option( - help="List of private keys of signers.", - rich_help_panel="Optional Arguments", - show_default=False, - callback=check_private_keys, - ), - ] = None, - safe_nonce: Annotated[ - int, - typer.Option( - help="Force nonce for tx_sender", - rich_help_panel="Optional Arguments", - show_default=False, - ), - ] = None, -): - safe_operator = _build_safe_operator(safe_address, node_url, private_key) - safe_operator.send_erc20(to, token_address, amount, safe_nonce=safe_nonce) - - -@app.command() -def send_erc721( - safe_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of the Safe.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], - to: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of destination.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - token_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="Erc721 token address.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - token_id: Annotated[ - int, typer.Argument(help="Erc721 token id.", show_default=False) - ], - private_key: Annotated[ - List[str], - typer.Option( - help="List of private keys of signers.", - rich_help_panel="Optional Arguments", - show_default=False, - callback=check_private_keys, - ), - ] = None, - safe_nonce: Annotated[ - int, - typer.Option( - help="Force nonce for tx_sender", - rich_help_panel="Optional Arguments", - show_default=False, - ), - ] = None, -): - safe_operator = _build_safe_operator(safe_address, node_url, private_key) - safe_operator.send_erc721(to, token_address, token_id, safe_nonce=safe_nonce) - - -@app.command() -def send_custom( - safe_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of the Safe.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], - to: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of destination.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - value: Annotated[int, typer.Argument(help="Value to send.", show_default=False)], - data: Annotated[ - HexBytes, - typer.Argument( - help="HexBytes data to send.", - callback=check_hex_str, - parser=parse_hex_str, - show_default=False, - ), - ], - private_key: Annotated[ - List[str], - typer.Option( - help="List of private keys of signers.", - rich_help_panel="Optional Arguments", - show_default=False, - callback=check_private_keys, - ), - ] = None, - safe_nonce: Annotated[ - int, - typer.Option( - help="Force nonce for tx_sender", - rich_help_panel="Optional Arguments", - show_default=False, - ), - ] = None, - delegate: Annotated[ - bool, - typer.Option( - help="Use DELEGATE_CALL. By default use CALL", - rich_help_panel="Optional Arguments", - ), - ] = False, -): - safe_operator = _build_safe_operator(safe_address, node_url, private_key) - safe_operator.send_custom( - to, value, data, safe_nonce=safe_nonce, delegate_call=delegate - ) - - -@app.command() -def tx_builder( - safe_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of the Safe.", - callback=check_ethereum_address, - parser=parse_checksum_address, - show_default=False, - ), - ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], - file_path: Annotated[ - Path, - typer.Argument( - exists=True, - file_okay=True, - dir_okay=False, - writable=False, - readable=True, - resolve_path=True, - help="File path with tx_builder data.", - show_default=False, - ), - ], - private_key: Annotated[ - List[str], - typer.Option( - help="List of private keys of signers.", - rich_help_panel="Optional Arguments", - show_default=False, - callback=check_private_keys, - ), - ] = None, -): - safe_operator = _build_safe_operator(safe_address, node_url, private_key) - data = json.loads(file_path.read_text()) - safe_txs = [] - for tx in convert_to_proposed_transactions(data): - safe_txs.append( - safe_operator.prepare_safe_transaction(tx.to, tx.value, tx.data) - ) - - if len(safe_txs) == 0: - raise typer.BadParameter("No transactions found.") - - if len(safe_txs) == 1: - safe_operator.execute_safe_transaction(safe_txs[0]) - return - - multisend_tx = safe_operator.batch_safe_txs(safe_operator.get_nonce(), safe_txs) - if multisend_tx is not None: - safe_operator.execute_safe_transaction(multisend_tx) - - -@app.command() -def version(): - print(f"Safe Runner v{VERSION}") - - -def main(): - app() diff --git a/src/safe_cli/utils.py b/src/safe_cli/utils.py index aca2d4df..7dd7cea3 100644 --- a/src/safe_cli/utils.py +++ b/src/safe_cli/utils.py @@ -82,9 +82,7 @@ def choose_option_from_list( return option -def get_safe_from_owner( - owner: ChecksumAddress, node_url: str -) -> Optional[ChecksumAddress]: +def get_safe_from_owner(owner: ChecksumAddress, node_url: str) -> ChecksumAddress: """ Show a list of Safe to chose between them and return the selected one. :param owner: @@ -98,7 +96,8 @@ def get_safe_from_owner( option = choose_option_from_list( "Select the Safe to initialize the safe-cli", safes ) - if option is not None: - return safes[option] + if option is None: + raise ValueError("Unable to load Safe to initialize the safe-cli") + return safes[option] else: raise ValueError(f"No safe was found for the specified owner {owner}") diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py deleted file mode 100644 index dbd68f5f..00000000 --- a/tests/test_entrypoint.py +++ /dev/null @@ -1,107 +0,0 @@ -import argparse -import unittest -from unittest import mock -from unittest.mock import MagicMock - -from eth_account import Account -from prompt_toolkit import HTML - -from gnosis.eth.constants import NULL_ADDRESS -from gnosis.eth.ethereum_client import EthereumClient -from gnosis.safe import Safe -from gnosis.safe.api import TransactionServiceApi -from gnosis.safe.safe import SafeInfo - -from safe_cli.main import SafeCli, build_safe_cli -from safe_cli.operators import SafeOperator - -from .safe_cli_test_case_mixin import SafeCliTestCaseMixin - - -class SafeCliEntrypointTestCase(SafeCliTestCaseMixin, unittest.TestCase): - random_safe_address = Account.create().address - - @mock.patch("argparse.ArgumentParser.parse_args") - def build_test_safe_cli(self, mock_parse_args: MagicMock): - mock_parse_args.return_value = argparse.Namespace( - address=self.random_safe_address, - node_url=self.ethereum_node_url, - history=True, - get_safes_from_owner=False, - ) - return build_safe_cli() - - @mock.patch("argparse.ArgumentParser.parse_args") - def build_test_safe_cli_for_owner(self, mock_parse_args: MagicMock): - mock_parse_args.return_value = argparse.Namespace( - address=self.random_safe_address, - node_url=self.ethereum_node_url, - history=True, - get_safes_from_owner=True, - ) - return build_safe_cli() - - @mock.patch.object(Safe, "retrieve_all_info") - def test_build_safe_cli(self, retrieve_all_info_mock: MagicMock): - retrieve_all_info_mock.return_value = SafeInfo( - self.random_safe_address, - "0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99", - NULL_ADDRESS, - "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762", - [], - 0, - [Account.create().address], - 1, - "1.4.1", - ) - - safe_cli = self.build_test_safe_cli() - with mock.patch.object(SafeOperator, "is_version_updated", return_value=True): - self.assertIsNone(safe_cli.print_startup_info()) - self.assertIsInstance(safe_cli.get_prompt_text(), HTML) - self.assertIsInstance(safe_cli.get_bottom_toolbar(), HTML) - - @mock.patch.object(EthereumClient, "get_chain_id", return_value=5) - @mock.patch.object(TransactionServiceApi, "get_safes_for_owner") - @mock.patch.object(Safe, "retrieve_all_info") - def test_build_safe_cli_for_owner( - self, - retrieve_all_info_mock: MagicMock, - get_safes_for_owner_mock: MagicMock, - get_chain_id_mock: MagicMock, - ): - retrieve_all_info_mock.return_value = SafeInfo( - self.random_safe_address, - "0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99", - NULL_ADDRESS, - "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762", - [], - 0, - [Account.create().address], - 1, - "1.4.1", - ) - get_safes_for_owner_mock.return_value = [] - with self.assertRaises(ValueError): - self.build_test_safe_cli_for_owner() - get_safes_for_owner_mock.return_value = [self.random_safe_address] - safe_cli = self.build_test_safe_cli_for_owner() - self.assertIsNotNone(safe_cli) - with mock.patch.object(SafeOperator, "is_version_updated", return_value=True): - self.assertIsNone(safe_cli.print_startup_info()) - self.assertIsInstance(safe_cli.get_prompt_text(), HTML) - self.assertIsInstance(safe_cli.get_bottom_toolbar(), HTML) - - def test_parse_operator_mode(self): - safe_cli = self.build_test_safe_cli() - self.assertIsNone(safe_cli.parse_operator_mode("tx-service")) - self.assertIsInstance(safe_cli.parse_operator_mode("blockchain"), SafeOperator) - - @mock.patch.object(SafeCli, "get_command", side_effect=EOFError) - def test_loop(self, mock_parse_args: MagicMock): - safe_cli = self.build_test_safe_cli() - safe_cli.loop() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_safe_runner.py b/tests/test_safe_cli_entry_point.py similarity index 70% rename from tests/test_safe_runner.py rename to tests/test_safe_cli_entry_point.py index 7df4b5c4..28cd5192 100644 --- a/tests/test_safe_runner.py +++ b/tests/test_safe_cli_entry_point.py @@ -1,29 +1,40 @@ import os import unittest +from unittest import mock +from unittest.mock import MagicMock +import pytest from eth_account import Account from eth_typing import HexStr from typer.testing import CliRunner +from gnosis.eth import EthereumClient +from gnosis.eth.constants import NULL_ADDRESS +from gnosis.safe import Safe +from gnosis.safe.api import TransactionServiceApi +from gnosis.safe.safe import SafeInfo + from safe_cli import VERSION +from safe_cli.main import app from safe_cli.operators.exceptions import ( NotEnoughEtherToSend, SafeOperatorException, SenderRequiredException, ) -from safe_cli.safe_runner import app +from safe_cli.safe_cli import SafeCli from .safe_cli_test_case_mixin import SafeCliTestCaseMixin runner = CliRunner() -class TestSafeRunner(SafeCliTestCaseMixin, unittest.TestCase): +class TestSafeCliEntryPoint(SafeCliTestCaseMixin, unittest.TestCase): + random_safe_address = Account.create().address def test_version(self): result = runner.invoke(app, ["version"]) self.assertEqual(result.exit_code, 0) - self.assertIn(f"Safe Runner v{VERSION}", result.stdout) + self.assertIn(f"Safe Cli v{VERSION}", result.stdout) def test_send_ether(self): safe_operator = self.setup_operator() @@ -274,6 +285,100 @@ def _send_eth_to(self, address: str, value: int) -> None: gas=50000, ) + @mock.patch.object(Safe, "retrieve_all_info") + def test_build_safe_cli(self, retrieve_all_info_mock: MagicMock): + safe_owner = Account.create().address + retrieve_all_info_mock.return_value = SafeInfo( + self.random_safe_address, + "0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99", + NULL_ADDRESS, + "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762", + [], + 0, + [safe_owner], + 1, + "1.4.1", + ) + result = runner.invoke( + app, + [ + "attended-mode", + self.random_safe_address, + "http://localhost:8545", + "--history", + ], + ) + self.assertEqual(result.exit_code, 0) + + @mock.patch.object(EthereumClient, "get_chain_id", return_value=5) + @mock.patch.object(TransactionServiceApi, "get_safes_for_owner") + @mock.patch.object(Safe, "retrieve_all_info") + def test_build_safe_cli_for_owner( + self, + retrieve_all_info_mock: MagicMock, + get_safes_for_owner_mock: MagicMock, + get_chain_id_mock: MagicMock, + ): + safe_owner = Account.create().address + retrieve_all_info_mock.return_value = SafeInfo( + self.random_safe_address, + "0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99", + NULL_ADDRESS, + "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762", + [], + 0, + [safe_owner], + 1, + "1.4.1", + ) + get_safes_for_owner_mock.return_value = [] + + result = runner.invoke( + app, + [ + "attended-mode", + safe_owner, + "http://localhost:8545", + "--history", + "--get-safes-from-owner", + ], + input="", + ) + exception, _, _ = result.exc_info + self.assertEqual(exception, ValueError) + self.assertEqual(result.exit_code, 1) + + @mock.patch.object(Safe, "retrieve_all_info") + @mock.patch.object(SafeCli, "get_command") + def test_parse_operator_mode( + self, get_command_mock: MagicMock, retrieve_all_info_mock: MagicMock + ): + safe_owner = Account.create().address + retrieve_all_info_mock.return_value = SafeInfo( + self.random_safe_address, + "0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99", + NULL_ADDRESS, + "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762", + [], + 0, + [safe_owner], + 1, + "1.4.1", + ) + get_command_mock.side_effect = ["tx-service", "exit"] + + result = runner.invoke( + app, + [ + "attended-mode", + self.random_safe_address, + "http://localhost:8545", + "--history", + ], + ) + + self.assertEqual(result.exit_code, 0) + if __name__ == "__main__": - unittest.main() + pytest.main() From 1e1d3548bd4c283e2de19261d3936d82d86bf698 Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Tue, 25 Jun 2024 10:58:44 +0200 Subject: [PATCH 09/14] Apply PR suggestions --- README.md | 4 +-- src/safe_cli/main.py | 46 ++++++++++++------------- src/safe_cli/operators/safe_operator.py | 21 +++++------ src/safe_cli/safe_cli.py | 11 +++--- src/safe_cli/typer_validators.py | 29 ++++++++++------ tests/test_typer_validators.py | 11 +++--- 6 files changed, 63 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index a87c422a..1c028ef1 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ usage: safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN ╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * address PARSE_CHECKSUM_ADDRESS The address of the Safe, or an owner address if --get-safes-from-owner is specified. [required] │ -│ * node_url TEXT Ethereum node url. [required] │ +│ * address CHECKSUMADDRESS The address of the Safe, or an owner address if --get-safes-from-owner is specified. [required] │ +│ * node_url TEXT Ethereum node url. [required] │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ --help Show this message and exit. │ diff --git a/src/safe_cli/main.py b/src/safe_cli/main.py index 61cd05ea..aafc3d82 100644 --- a/src/safe_cli/main.py +++ b/src/safe_cli/main.py @@ -9,18 +9,18 @@ from hexbytes import HexBytes from typer.main import get_command, get_command_name -from safe_cli import VERSION -from safe_cli.argparse_validators import check_hex_str -from safe_cli.operators import SafeOperator -from safe_cli.safe_cli import SafeCli -from safe_cli.tx_builder.tx_builder_file_decoder import convert_to_proposed_transactions -from safe_cli.typer_validators import ( +from . import VERSION +from .argparse_validators import check_hex_str +from .operators import SafeOperator +from .safe_cli import SafeCli +from .tx_builder.tx_builder_file_decoder import convert_to_proposed_transactions +from .typer_validators import ( + ChecksumAddressParser, + HexBytesParser, check_ethereum_address, check_private_keys, - parse_checksum_address, - parse_hex_str, ) -from safe_cli.utils import get_safe_from_owner +from .utils import get_safe_from_owner app = typer.Typer(name="Safe CLI") @@ -28,7 +28,7 @@ def _build_safe_operator_and_load_keys( safe_address: ChecksumAddress, node_url: str, private_keys: List[str] ) -> SafeOperator: - safe_operator = SafeOperator(safe_address, node_url, script_mode=True) + safe_operator = SafeOperator(safe_address, node_url, no_input=True) safe_operator.load_cli_owners(private_keys) return safe_operator @@ -40,7 +40,7 @@ def send_ether( typer.Argument( help="The address of the Safe.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -52,7 +52,7 @@ def send_ether( typer.Argument( help="The address of destination.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -90,7 +90,7 @@ def send_erc20( typer.Argument( help="The address of the Safe.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -102,7 +102,7 @@ def send_erc20( typer.Argument( help="The address of destination.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -111,7 +111,7 @@ def send_erc20( typer.Argument( help="Erc20 token address.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -152,7 +152,7 @@ def send_erc721( typer.Argument( help="The address of the Safe.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -164,7 +164,7 @@ def send_erc721( typer.Argument( help="The address of destination.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -173,7 +173,7 @@ def send_erc721( typer.Argument( help="Erc721 token address.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -211,7 +211,7 @@ def send_custom( typer.Argument( help="The address of the Safe.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -223,7 +223,7 @@ def send_custom( typer.Argument( help="The address of destination.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -233,7 +233,7 @@ def send_custom( typer.Argument( help="HexBytes data to send.", callback=check_hex_str, - parser=parse_hex_str, + click_type=HexBytesParser(), show_default=False, ), ], @@ -277,7 +277,7 @@ def tx_builder( typer.Argument( help="The address of the Safe.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], @@ -365,7 +365,7 @@ def default_attended_mode( typer.Argument( help="The address of the Safe, or an owner address if --get-safes-from-owner is specified.", callback=check_ethereum_address, - parser=parse_checksum_address, + click_type=ChecksumAddressParser(), show_default=False, ), ], diff --git a/src/safe_cli/operators/safe_operator.py b/src/safe_cli/operators/safe_operator.py index a3a770ac..80c38e8b 100644 --- a/src/safe_cli/operators/safe_operator.py +++ b/src/safe_cli/operators/safe_operator.py @@ -151,11 +151,9 @@ class SafeOperator: executed_transactions: List[str] _safe_cli_info: Optional[SafeCliInfo] require_all_signatures: bool - script_mode: bool + no_input: bool - def __init__( - self, address: ChecksumAddress, node_url: str, script_mode: bool = False - ): + def __init__(self, address: ChecksumAddress, node_url: str, no_input: bool = False): self.address = address self.node_url = node_url self.ethereum_client = EthereumClient(self.node_url) @@ -186,7 +184,7 @@ def __init__( True # Require all signatures to be present to send a tx ) self.hw_wallet_manager = get_hw_wallet_manager() - self.script_mode = script_mode # Disable prompt dialogs + self.no_input = no_input # Disable prompt dialogs @cached_property def last_default_fallback_handler_address(self) -> ChecksumAddress: @@ -289,7 +287,7 @@ def load_cli_owners(self, keys: List[str]): ) self.default_sender = account except ValueError: - if self.script_mode: + if self.no_input: raise SafeOperatorException(f"Cannot load key={key}") print_formatted_text(HTML(f"Cannot load key={key}")) @@ -943,7 +941,9 @@ def execute_safe_transaction(self, safe_tx: SafeTx): else: call_result = safe_tx.call(self.hw_wallet_manager.sender.address) print_formatted_text(HTML(f"Result: {call_result}")) - if self._is_confirmed_transaction_execution(safe_tx): + if self.no_input or yes_or_no_question( + "Do you want to execute tx " + str(safe_tx) + ): if self.default_sender: tx_hash, tx = safe_tx.execute( self.default_sender.key, eip1559_speed=TxSpeed.NORMAL @@ -1002,7 +1002,7 @@ def batch_safe_txs( try: multisend = MultiSend(ethereum_client=self.ethereum_client) except ValueError: - if self.script_mode: + if self.no_input: raise SafeOperatorException( "Multisend contract is not deployed on this network and it's required for batching txs" ) @@ -1112,11 +1112,6 @@ def _require_tx_service_mode(self): ) ) - def _is_confirmed_transaction_execution(self, safe_tx: SafeTx) -> bool: - return self.script_mode or yes_or_no_question( - "Do you want to execute tx " + str(safe_tx) - ) - def get_delegates(self): return self._require_tx_service_mode() diff --git a/src/safe_cli/safe_cli.py b/src/safe_cli/safe_cli.py index 6bb069a8..514dc34d 100644 --- a/src/safe_cli/safe_cli.py +++ b/src/safe_cli/safe_cli.py @@ -10,17 +10,16 @@ from prompt_toolkit.history import FileHistory from prompt_toolkit.lexers import PygmentsLexer -from safe_cli.operators import ( +from . import VERSION +from .operators import ( SafeCliTerminationException, SafeOperator, SafeServiceNotAvailable, SafeTxServiceOperator, ) -from safe_cli.prompt_parser import PromptParser -from safe_cli.safe_completer import SafeCompleter -from safe_cli.safe_lexer import SafeLexer - -from . import VERSION +from .prompt_parser import PromptParser +from .safe_completer import SafeCompleter +from .safe_lexer import SafeLexer class SafeCli: diff --git a/src/safe_cli/typer_validators.py b/src/safe_cli/typer_validators.py index 10c043bd..5cdd4746 100644 --- a/src/safe_cli/typer_validators.py +++ b/src/safe_cli/typer_validators.py @@ -2,6 +2,7 @@ from binascii import Error from typing import List +import click import typer from eth_account import Account from eth_typing import ChecksumAddress @@ -18,11 +19,14 @@ def check_ethereum_address(address: str) -> ChecksumAddress: return ChecksumAddress(address) -def parse_checksum_address(address: str) -> ChecksumAddress: - """ - ChecksumAddress parser from str - """ - return ChecksumAddress(address) +class ChecksumAddressParser(click.ParamType): + name = "ChecksumAddress" + + def convert(self, value, param, ctx): + """ + ChecksumAddress parser from str + """ + return ChecksumAddress(value) def check_private_keys(private_keys: List[str]) -> List[str]: @@ -41,7 +45,7 @@ def check_private_keys(private_keys: List[str]) -> List[str]: def check_hex_str(hex_str: str) -> HexBytes: """ - Hexadecimal string validator + HexBytes string validator """ try: return HexBytes(hex_str) @@ -49,8 +53,11 @@ def check_hex_str(hex_str: str) -> HexBytes: raise typer.BadParameter(f"{hex_str} is not a valid hexadecimal string") -def parse_hex_str(data: str) -> HexBytes: - """ - Hexadecimal string parser from str - """ - return HexBytes(data) +class HexBytesParser(click.ParamType): + name = "HexBytes" + + def convert(self, value, param, ctx): + """ + HexBytes string parser from str + """ + return HexBytes(value) diff --git a/tests/test_typer_validators.py b/tests/test_typer_validators.py index 0601f314..74159a16 100644 --- a/tests/test_typer_validators.py +++ b/tests/test_typer_validators.py @@ -6,11 +6,11 @@ from hexbytes import HexBytes from safe_cli.typer_validators import ( + ChecksumAddressParser, + HexBytesParser, check_ethereum_address, check_hex_str, check_private_keys, - parse_checksum_address, - parse_hex_str, ) @@ -40,10 +40,13 @@ def test_check_hex_str(self): def test_parse_checksum_address(self): address = "0x4127839cdf4F73d9fC9a2C2861d8d1799e9DF40C" - self.assertEqual(parse_checksum_address(address), ChecksumAddress(address)) + self.assertEqual( + ChecksumAddressParser().convert(address, None, None), + ChecksumAddress(address), + ) def test_parse_hex_str(self): - self.assertEqual(parse_hex_str("0x12"), HexBytes("0x12")) + self.assertEqual(HexBytesParser().convert("0x12", None, None), HexBytes("0x12")) if __name__ == "__main__": From ded64e7a800979f5394137648871f3376d67b269 Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Tue, 25 Jun 2024 14:13:42 +0200 Subject: [PATCH 10/14] Revert tx_builder command --- README.md | 7 +- src/safe_cli/main.py | 63 ----- src/safe_cli/tx_builder/__init__.py | 0 src/safe_cli/tx_builder/exceptions.py | 10 - .../tx_builder/tx_builder_file_decoder.py | 240 ---------------- tests/mocks/tx_builder/batch_txs.json | 59 ---- tests/mocks/tx_builder/empty_txs.json | 14 - tests/mocks/tx_builder/single_tx.json | 40 --- tests/test_safe_cli_entry_point.py | 56 +--- tests/test_tx_builder_file_decoder.py | 257 ------------------ 10 files changed, 2 insertions(+), 744 deletions(-) delete mode 100644 src/safe_cli/tx_builder/__init__.py delete mode 100644 src/safe_cli/tx_builder/exceptions.py delete mode 100644 src/safe_cli/tx_builder/tx_builder_file_decoder.py delete mode 100644 tests/mocks/tx_builder/batch_txs.json delete mode 100644 tests/mocks/tx_builder/empty_txs.json delete mode 100644 tests/mocks/tx_builder/single_tx.json delete mode 100644 tests/test_tx_builder_file_decoder.py diff --git a/README.md b/README.md index 1c028ef1..3920b080 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,6 @@ usage: safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN - safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN - ╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ * address CHECKSUMADDRESS The address of the Safe, or an owner address if --get-safes-from-owner is specified. [required] │ │ * node_url TEXT Ethereum node url. [required] │ @@ -77,21 +75,18 @@ usage: send-erc20 send-erc721 send-custom - tx-builder version Use the --help option of each command to see the usage options. ``` -To execute transactions unattended, or execute transactions from a json exported from the tx_builder you can use: +To execute transactions unattended you can use: ```bash safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN - -safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN ``` ### Safe-Creator diff --git a/src/safe_cli/main.py b/src/safe_cli/main.py index aafc3d82..1a413188 100644 --- a/src/safe_cli/main.py +++ b/src/safe_cli/main.py @@ -1,7 +1,5 @@ #!/bin/env python3 -import json import sys -from pathlib import Path from typing import Annotated, List import typer @@ -13,7 +11,6 @@ from .argparse_validators import check_hex_str from .operators import SafeOperator from .safe_cli import SafeCli -from .tx_builder.tx_builder_file_decoder import convert_to_proposed_transactions from .typer_validators import ( ChecksumAddressParser, HexBytesParser, @@ -270,65 +267,6 @@ def send_custom( ) -@app.command() -def tx_builder( - safe_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of the Safe.", - callback=check_ethereum_address, - click_type=ChecksumAddressParser(), - show_default=False, - ), - ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], - file_path: Annotated[ - Path, - typer.Argument( - exists=True, - file_okay=True, - dir_okay=False, - writable=False, - readable=True, - resolve_path=True, - help="File path with tx_builder data.", - show_default=False, - ), - ], - private_key: Annotated[ - List[str], - typer.Option( - help="List of private keys of signers.", - rich_help_panel="Optional Arguments", - show_default=False, - callback=check_private_keys, - ), - ] = None, -): - safe_operator = _build_safe_operator_and_load_keys( - safe_address, node_url, private_key - ) - data = json.loads(file_path.read_text()) - safe_txs = [] - for tx in convert_to_proposed_transactions(data): - safe_txs.append( - safe_operator.prepare_safe_transaction(tx.to, tx.value, tx.data) - ) - - if len(safe_txs) == 0: - raise typer.BadParameter("No transactions found.") - - if len(safe_txs) == 1: - safe_operator.execute_safe_transaction(safe_txs[0]) - return - - multisend_tx = safe_operator.batch_safe_txs(safe_operator.get_nonce(), safe_txs) - if multisend_tx is not None: - safe_operator.execute_safe_transaction(multisend_tx) - - @app.command() def version(): print(f"Safe Cli v{VERSION}") @@ -348,7 +286,6 @@ def version(): safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN\n safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN\n safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN\n\n\n\n - safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN """, epilog="Commands available in unattended mode:\n\n\n\n" + "\n\n".join( diff --git a/src/safe_cli/tx_builder/__init__.py b/src/safe_cli/tx_builder/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/safe_cli/tx_builder/exceptions.py b/src/safe_cli/tx_builder/exceptions.py deleted file mode 100644 index d7a305e6..00000000 --- a/src/safe_cli/tx_builder/exceptions.py +++ /dev/null @@ -1,10 +0,0 @@ -class SoliditySyntaxError(Exception): - pass - - -class TxBuilderEncodingError(Exception): - pass - - -class InvalidContratMethodError(Exception): - pass diff --git a/src/safe_cli/tx_builder/tx_builder_file_decoder.py b/src/safe_cli/tx_builder/tx_builder_file_decoder.py deleted file mode 100644 index 857b7eeb..00000000 --- a/src/safe_cli/tx_builder/tx_builder_file_decoder.py +++ /dev/null @@ -1,240 +0,0 @@ -import dataclasses -import json -import re -from typing import Any, Dict, List - -from eth_abi import encode as encode_abi -from hexbytes import HexBytes -from web3 import Web3 - -from safe_cli.tx_builder.exceptions import ( - InvalidContratMethodError, - SoliditySyntaxError, - TxBuilderEncodingError, -) - -NON_VALID_CONTRACT_METHODS = ["receive", "fallback"] - - -def _parse_types_to_encoding_types(contract_fields: List[Dict[str, Any]]) -> List[Any]: - types = [] - - for field in contract_fields: - if is_tuple_field_type(field["type"]): - component_types = ",".join( - component["type"] for component in field["components"] - ) - types.append(f"({component_types})") - else: - types.append(field["type"]) - - return types - - -def encode_contract_method_to_hex_data( - contract_method: Dict[str, Any], contract_fields_values: Dict[str, Any] -) -> HexBytes: - contract_method_name = contract_method.get("name") if contract_method else None - contract_fields = contract_method.get("inputs", []) if contract_method else [] - - is_valid_contract_method = ( - contract_method_name and contract_method_name not in NON_VALID_CONTRACT_METHODS - ) - - if is_valid_contract_method: - try: - encoding_types = _parse_types_to_encoding_types(contract_fields) - values = [ - parse_input_value( - field["type"], contract_fields_values.get(field["name"], "") - ) - for field in contract_fields - ] - - function_signature = f"{contract_method_name}({','.join(encoding_types)})" - function_selector = Web3.keccak(text=function_signature)[:4] - encoded_parameters = encode_abi(encoding_types, values) - hex_encoded_data = HexBytes(function_selector + encoded_parameters) - return hex_encoded_data - except Exception as error: - raise TxBuilderEncodingError( - "Error encoding current form values to hex data:", error - ) - else: - raise InvalidContratMethodError( - f"Invalid contract method {contract_method_name}" - ) - - -def parse_boolean_value(value: str) -> bool: - if isinstance(value, str): - if value.strip().lower() in ["true", "1"]: - return True - - if value.strip().lower() in ["false", "0"]: - return False - - raise SoliditySyntaxError("Invalid Boolean value") - - return bool(value) - - -def parse_int_value(value: str) -> int: - trimmed_value = value.replace('"', "").replace("'", "").strip() - - if trimmed_value == "": - raise SoliditySyntaxError("Invalid empty strings for integers") - try: - if not trimmed_value.isdigit() and bool( - re.fullmatch(r"0[xX][0-9a-fA-F]+|[0-9a-fA-F]+$", trimmed_value) - ): - return int(trimmed_value, 16) - - return int(trimmed_value) - except ValueError: - raise SoliditySyntaxError("Invalid integer value") - - -def parse_string_to_array(value: str) -> List[Any]: - number_of_items = 0 - number_of_other_arrays = 0 - result = [] - value = value.strip()[1:-1] # remove the first "[" and the last "]" - - for char in value: - if char == "," and number_of_other_arrays == 0: - number_of_items += 1 - continue - - if char == "[": - number_of_other_arrays += 1 - elif char == "]": - number_of_other_arrays -= 1 - - if len(result) <= number_of_items: - result.append("") - - result[number_of_items] += char.strip() - - return result - - -def _get_base_field_type(field_type: str) -> str: - trimmed_value = field_type.strip() - if not trimmed_value: - raise SoliditySyntaxError("Empty base field type for") - - base_field_type_regex = re.compile( - r"^([a-zA-Z0-9]*)(((\[\])|(\[[1-9]+[0-9]*\]))*)?$" - ) - match = base_field_type_regex.match(trimmed_value) - if not match: - raise SoliditySyntaxError(f"Unknown base field type from {trimmed_value}") - return match.group(1) - - -def _is_array(values: str) -> bool: - trimmed_value = values.strip() - return trimmed_value.startswith("[") and trimmed_value.endswith("]") - - -def parse_array_of_values(values: str, field_type: str) -> List[Any]: - if not _is_array(values): - raise SoliditySyntaxError("Invalid Array value") - - parsed_values = parse_string_to_array(values) - return [ - ( - parse_array_of_values(item_value, field_type) - if _is_array(item_value) - else parse_input_value(_get_base_field_type(field_type), item_value) - ) - for item_value in parsed_values - ] - - -def is_boolean_field_type(field_type: str) -> bool: - return field_type == "bool" - - -def is_int_field_type(field_type: str) -> bool: - return field_type.startswith("uint") or field_type.startswith("int") - - -def is_tuple_field_type(field_type: str) -> bool: - return field_type.startswith("tuple") - - -def is_bytes_field_type(field_type: str) -> bool: - return field_type.startswith("bytes") - - -def is_array_of_strings_field_type(field_type: str) -> bool: - return field_type.startswith("string[") - - -def is_array_field_type(field_type: str) -> bool: - pattern = re.compile(r"\[\d*\]$") - return bool(pattern.search(field_type)) - - -def is_multi_dimensional_array_field_type(field_type: str) -> bool: - return field_type.count("[") > 1 - - -def parse_input_value(field_type: str, value: str) -> Any: - trimmed_value = value.strip() if isinstance(value, str) else value - - if is_tuple_field_type(field_type): - return tuple(json.loads(trimmed_value)) - - if is_array_of_strings_field_type(field_type): - return json.loads(trimmed_value) - - if is_array_field_type(field_type) or is_multi_dimensional_array_field_type( - field_type - ): - return parse_array_of_values(trimmed_value, field_type) - - if is_boolean_field_type(field_type): - return parse_boolean_value(trimmed_value) - - if is_int_field_type(field_type): - return parse_int_value(trimmed_value) - - if is_bytes_field_type(field_type): - return HexBytes(trimmed_value) - - return trimmed_value - - -@dataclasses.dataclass -class SafeProposedTx: - id: int - to: str - value: int - data: str - - def __str__(self): - return f"id={self.id} to={self.to} value={self.value} data={self.data}" - - -def convert_to_proposed_transactions( - batch_file: Dict[str, Any] -) -> List[SafeProposedTx]: - proposed_transactions = [] - for index, transaction in enumerate(batch_file["transactions"]): - proposed_transactions.append( - SafeProposedTx( - id=index, - to=transaction.get("to"), - value=transaction.get("value"), - data=transaction.get("data") - or encode_contract_method_to_hex_data( - transaction.get("contractMethod"), - transaction.get("contractInputsValues"), - ).hex() - or "0x", - ) - ) - return proposed_transactions diff --git a/tests/mocks/tx_builder/batch_txs.json b/tests/mocks/tx_builder/batch_txs.json deleted file mode 100644 index 79728d0c..00000000 --- a/tests/mocks/tx_builder/batch_txs.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "version":"1.0", - "chainId":"11155111", - "createdAt":1718723305452, - "meta":{ - "name":"Transactions Batch", - "description":"", - "txBuilderVersion":"1.16.5", - "createdFromSafeAddress":"0xFFFFFFFF964459F3C984682f78A4d30713174b2E", - "createdFromOwnerAddress":"", - "checksum":"0x69d3b5239a5bdb8933c300000000006aba85e6ce721acc51721268896a66b79f" - }, - "transactions":[ - { - "to":"0xd16d9C09d13E9Cf77615771eADC5d51a1Ae92a26", - "value":"0", - "data":null, - "contractMethod":{ - "inputs":[ - { - "internalType":"address", - "name":"spender", - "type":"address" - }, - { - "internalType":"uint256", - "name":"amount", - "type":"uint256" - } - ], - "name":"approve", - "payable":false - }, - "contractInputsValues":{ - "spender":"0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", - "amount":"10" - } - }, - { - "to":"0xb161ccb96b9b817F9bDf0048F212725128779DE9", - "value":"0", - "data":null, - "contractMethod":{ - "inputs":[ - { - "internalType":"uint96", - "name":"amount", - "type":"uint96" - } - ], - "name":"lock", - "payable":false - }, - "contractInputsValues":{ - "amount":"10" - } - } - ] - } \ No newline at end of file diff --git a/tests/mocks/tx_builder/empty_txs.json b/tests/mocks/tx_builder/empty_txs.json deleted file mode 100644 index 2b64c1dc..00000000 --- a/tests/mocks/tx_builder/empty_txs.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version":"1.0", - "chainId":"11155111", - "createdAt":1718723305452, - "meta":{ - "name":"Transactions Batch", - "description":"", - "txBuilderVersion":"1.16.5", - "createdFromSafeAddress":"0xFFFFFFFF964459F3C984682f78A4d30713174b2E", - "createdFromOwnerAddress":"", - "checksum":"0x69d3b5239a5bdb8933c300000000006aba85e6ce721acc51721268896a66b79f" - }, - "transactions":[] - } \ No newline at end of file diff --git a/tests/mocks/tx_builder/single_tx.json b/tests/mocks/tx_builder/single_tx.json deleted file mode 100644 index 21518e5d..00000000 --- a/tests/mocks/tx_builder/single_tx.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "version":"1.0", - "chainId":"11155111", - "createdAt":1718723305452, - "meta":{ - "name":"Transactions Batch", - "description":"", - "txBuilderVersion":"1.16.5", - "createdFromSafeAddress":"0xFFFFFFFF964459F3C984682f78A4d30713174b2E", - "createdFromOwnerAddress":"", - "checksum":"0x69d3b5239a5bdb8933c300000000006aba85e6ce721acc51721268896a66b79f" - }, - "transactions":[ - { - "to":"0xd16d9C09d13E9Cf77615771eADC5d51a1Ae92a26", - "value":"0", - "data":null, - "contractMethod":{ - "inputs":[ - { - "internalType":"address", - "name":"spender", - "type":"address" - }, - { - "internalType":"uint256", - "name":"amount", - "type":"uint256" - } - ], - "name":"approve", - "payable":false - }, - "contractInputsValues":{ - "spender":"0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", - "amount":"10" - } - } - ] - } \ No newline at end of file diff --git a/tests/test_safe_cli_entry_point.py b/tests/test_safe_cli_entry_point.py index 28cd5192..d3ccbcd7 100644 --- a/tests/test_safe_cli_entry_point.py +++ b/tests/test_safe_cli_entry_point.py @@ -16,11 +16,7 @@ from safe_cli import VERSION from safe_cli.main import app -from safe_cli.operators.exceptions import ( - NotEnoughEtherToSend, - SafeOperatorException, - SenderRequiredException, -) +from safe_cli.operators.exceptions import NotEnoughEtherToSend, SenderRequiredException from safe_cli.safe_cli import SafeCli from .safe_cli_test_case_mixin import SafeCliTestCaseMixin @@ -226,56 +222,6 @@ def test_send_custom(self): ) self.assertEqual(result.exit_code, 0) - def test_tx_builder(self): - safe_operator = self.setup_operator() - safe_owner = Account.create() - safe_operator.add_owner(safe_owner.address, 1) - self._send_eth_to(safe_owner.address, 1000000000000000000) - - # Test exit code 1 with empty file - result = runner.invoke( - app, - [ - "tx-builder", - safe_operator.safe.address, - "http://localhost:8545", - "tests/mocks/tx_builder/empty_txs.json", - "--private-key", - safe_owner.key.hex(), - ], - ) - self.assertEqual(result.exit_code, 2) - - # Test single tx exit 0 - result = runner.invoke( - app, - [ - "tx-builder", - safe_operator.safe.address, - "http://localhost:8545", - "tests/mocks/tx_builder/single_tx.json", - "--private-key", - safe_owner.key.hex(), - ], - ) - self.assertEqual(result.exit_code, 0) - - # Test batch txs (Ends with exception because the multisend contract is not deployed.) - result = runner.invoke( - app, - [ - "tx-builder", - safe_operator.safe.address, - "http://localhost:8545", - "tests/mocks/tx_builder/batch_txs.json", - "--private-key", - safe_owner.key.hex(), - ], - ) - exception, _, _ = result.exc_info - self.assertEqual(exception, SafeOperatorException) - self.assertEqual(result.exit_code, 1) - def _send_eth_to(self, address: str, value: int) -> None: self.ethereum_client.send_eth_to( self.ethereum_test_account.key, diff --git a/tests/test_tx_builder_file_decoder.py b/tests/test_tx_builder_file_decoder.py deleted file mode 100644 index 5475565c..00000000 --- a/tests/test_tx_builder_file_decoder.py +++ /dev/null @@ -1,257 +0,0 @@ -import unittest - -from eth_abi import encode as encode_abi -from hexbytes import HexBytes -from web3 import Web3 - -from safe_cli.tx_builder.exceptions import ( - InvalidContratMethodError, - SoliditySyntaxError, - TxBuilderEncodingError, -) -from safe_cli.tx_builder.tx_builder_file_decoder import ( - SafeProposedTx, - _get_base_field_type, - convert_to_proposed_transactions, - encode_contract_method_to_hex_data, - parse_array_of_values, - parse_boolean_value, - parse_input_value, - parse_int_value, - parse_string_to_array, -) - -from .safe_cli_test_case_mixin import SafeCliTestCaseMixin - - -class TestTxBuilderFileDecoder(SafeCliTestCaseMixin, unittest.TestCase): - def test_parse_boolean_value(self): - self.assertTrue(parse_boolean_value("true")) - self.assertTrue(parse_boolean_value(" TRUE ")) - self.assertTrue(parse_boolean_value("1")) - self.assertTrue(parse_boolean_value(" 1 ")) - self.assertFalse(parse_boolean_value("false")) - self.assertFalse(parse_boolean_value(" FALSE ")) - self.assertFalse(parse_boolean_value("0")) - self.assertFalse(parse_boolean_value(" 0 ")) - with self.assertRaises(SoliditySyntaxError): - parse_boolean_value("notabool") - self.assertTrue(parse_boolean_value(True)) - self.assertFalse(parse_boolean_value(False)) - self.assertTrue(parse_boolean_value(1)) - self.assertFalse(parse_boolean_value(0)) - - def test_parse_int_value(self): - self.assertEqual(parse_int_value("123"), 123) - self.assertEqual(parse_int_value("'789'"), 789) - self.assertEqual(parse_int_value('" 101112 "'), 101112) - self.assertEqual(parse_int_value("0x1A"), 26) - self.assertEqual(parse_int_value("0X1a"), 26) - self.assertEqual(parse_int_value(" 0x123 "), 291) - with self.assertRaises(SoliditySyntaxError): - parse_int_value(" ") - with self.assertRaises(SoliditySyntaxError): - parse_int_value("0x1G") - - def test_parse_string_to_array(self): - self.assertEqual(parse_string_to_array("[a,b,c]"), ["a", "b", "c"]) - self.assertEqual(parse_string_to_array("[1,2,3]"), ["1", "2", "3"]) - self.assertEqual(parse_string_to_array("[hello,world]"), ["hello", "world"]) - self.assertEqual(parse_string_to_array("[[a,b],[c,d]]"), ["[a,b]", "[c,d]"]) - self.assertEqual(parse_string_to_array("[ a , b , c ]"), ["a", "b", "c"]) - self.assertEqual( - parse_string_to_array('["[hello,world]","[foo,bar]"]'), - ['"[hello,world]"', '"[foo,bar]"'], - ) - - def test_get_base_field_type(self): - self.assertEqual(_get_base_field_type("uint"), "uint") - self.assertEqual(_get_base_field_type("int"), "int") - self.assertEqual(_get_base_field_type("address"), "address") - self.assertEqual(_get_base_field_type("bool"), "bool") - self.assertEqual(_get_base_field_type("string"), "string") - self.assertEqual(_get_base_field_type("uint[]"), "uint") - self.assertEqual(_get_base_field_type("int[10]"), "int") - self.assertEqual(_get_base_field_type("address[5][]"), "address") - self.assertEqual(_get_base_field_type("bool[][]"), "bool") - self.assertEqual(_get_base_field_type("string[3][4]"), "string") - self.assertEqual(_get_base_field_type("uint256"), "uint256") - self.assertEqual(_get_base_field_type("myCustomType[10][]"), "myCustomType") - with self.assertRaises(SoliditySyntaxError): - _get_base_field_type("[int]") - with self.assertRaises(SoliditySyntaxError): - _get_base_field_type("") - - def test_parse_array_of_values(self): - self.assertEqual(parse_array_of_values("[1,2,3]", "uint[]"), [1, 2, 3]) - self.assertEqual( - parse_array_of_values("[true,false,true]", "bool[]"), [True, False, True] - ) - self.assertEqual( - parse_array_of_values('["hello","world"]', "string[]"), - ['"hello"', '"world"'], - ) - self.assertEqual( - parse_array_of_values("[hello,world]", "string[]"), ["hello", "world"] - ) - self.assertEqual( - parse_array_of_values("[[1,2],[3,4]]", "uint[][]"), [[1, 2], [3, 4]] - ) - self.assertEqual( - parse_array_of_values("[[true,false],[false,true]]", "bool[][]"), - [[True, False], [False, True]], - ) - self.assertEqual( - parse_array_of_values('[["hello","world"],["foo","bar"]]', "string[][]"), - [['"hello"', '"world"'], ['"foo"', '"bar"']], - ) - self.assertEqual( - parse_array_of_values("[0x123, 0x456]", "address[]"), ["0x123", "0x456"] - ) - self.assertEqual( - parse_array_of_values("[[0x123], [0x456]]", "address[][]"), - [["0x123"], ["0x456"]], - ) - with self.assertRaises(SoliditySyntaxError): - parse_array_of_values("1,2,3", "uint[]") - - def test_parse_input_value(self): - self.assertEqual(parse_input_value("tuple", "[1,2,3]"), (1, 2, 3)) - self.assertEqual( - parse_input_value("string[]", '["a", "b", "c"]'), ["a", "b", "c"] - ) - self.assertEqual(parse_input_value("uint[]", "[1, 2, 3]"), [1, 2, 3]) - self.assertEqual( - parse_input_value("uint[2][2]", "[[1, 2], [3, 4]]"), [[1, 2], [3, 4]] - ) - self.assertTrue(parse_input_value("bool", "true")) - self.assertEqual(parse_input_value("int", "123"), 123) - self.assertEqual(parse_input_value("bytes", "0x1234"), HexBytes("0x1234")) - - def test_encode_contract_method_to_hex_data(self): - contract_method = { - "name": "transfer", - "inputs": [ - {"name": "to", "type": "address"}, - {"name": "value", "type": "uint256"}, - ], - } - contract_fields_values = { - "to": "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", - "value": "1000", - } - expected_hex = HexBytes( - Web3.keccak(text="transfer(address,uint256)")[:4] - + encode_abi( - ["address", "uint256"], - ["0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", 1000], - ) - ) - self.assertEqual( - encode_contract_method_to_hex_data(contract_method, contract_fields_values), - expected_hex, - ) - - # Test tuple type - contract_method = { - "name": "transfer", - "inputs": [ - {"name": "to", "type": "address"}, - { - "components": [ - {"name": "name", "type": "string"}, - {"name": "age", "type": "uint8"}, - {"name": "userAddress", "type": "address"}, - {"name": "isNice", "type": "bool"}, - ], - "name": "contractOwnerNewValue", - "type": "tuple", - }, - ], - } - contract_fields_values = { - "to": "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", - "contractOwnerNewValue": '["hola",12,"0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5",true]', - } - expected_hex = HexBytes( - Web3.keccak(text="transfer(address,(string,uint8,address,bool))")[:4] - + encode_abi( - ["address", "(string,uint8,address,bool)"], - [ - "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", - ("hola", 12, "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", True), - ], - ) - ) - self.assertEqual( - encode_contract_method_to_hex_data(contract_method, contract_fields_values), - expected_hex, - ) - - # Test invalid contrat method - contract_method = {"name": "receive", "inputs": []} - contract_fields_values = {} - with self.assertRaises(InvalidContratMethodError): - encode_contract_method_to_hex_data(contract_method, contract_fields_values) - - # Test invalid value - contract_method = { - "name": "transfer", - "inputs": [ - {"name": "to", "type": "address"}, - {"name": "value", "type": "uint256"}, - ], - } - contract_fields_values = {"to": "0xRecipientAddress", "value": "invalidValue"} - with self.assertRaises(TxBuilderEncodingError): - encode_contract_method_to_hex_data(contract_method, contract_fields_values) - - def test_safe_proposed_tx_str(self): - tx = SafeProposedTx(id=1, to="0xRecipientAddress", value=1000, data="0x1234") - self.assertEqual(str(tx), "id=1 to=0xRecipientAddress value=1000 data=0x1234") - - def test_convert_to_proposed_transactions(self): - batch_file = { - "transactions": [ - { - "to": "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", - "value": 1000, - "data": "0x1234", - }, - { - "to": "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", - "value": 2000, - "contractMethod": { - "name": "transfer", - "inputs": [ - {"name": "to", "type": "address"}, - {"name": "value", "type": "uint256"}, - ], - }, - "contractInputsValues": { - "to": "0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", - "value": "1000", - }, - }, - ] - } - expected = [ - SafeProposedTx( - id=0, - to="0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", - value=1000, - data="0x1234", - ), - SafeProposedTx( - id=1, - to="0x21C98F24ACC673b9e1Ad2C4191324701576CC2E5", - value=2000, - data="0xa9059cbb00000000000000000000000021c98f24acc673b9e1ad2c4191324701576cc2e500000000000000000000000000000000000000000000000000000000000003e8", - ), - ] - result = convert_to_proposed_transactions(batch_file) - self.assertEqual(result, expected) - - -if __name__ == "__main__": - unittest.main() From 7f61716537adc770043c238b88587ea86260729f Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Tue, 25 Jun 2024 17:38:11 +0200 Subject: [PATCH 11/14] Update feedback messages to user. --- src/safe_cli/main.py | 5 +++++ src/safe_cli/safe_cli.py | 4 ---- src/safe_cli/utils.py | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/safe_cli/main.py b/src/safe_cli/main.py index 1a413188..51f69920 100644 --- a/src/safe_cli/main.py +++ b/src/safe_cli/main.py @@ -3,8 +3,10 @@ from typing import Annotated, List import typer +from art import text2art from eth_typing import ChecksumAddress from hexbytes import HexBytes +from prompt_toolkit import HTML, print_formatted_text from typer.main import get_command, get_command_name from . import VERSION @@ -324,6 +326,9 @@ def default_attended_mode( ), ] = False, ) -> None: + print_formatted_text(text2art("Safe CLI")) # Print fancy text + print_formatted_text(HTML(f"Version: {VERSION}")) + if get_safes_from_owner: safe_address_listed = get_safe_from_owner(address, node_url) safe_cli = SafeCli(safe_address_listed, node_url, history) diff --git a/src/safe_cli/safe_cli.py b/src/safe_cli/safe_cli.py index 514dc34d..af5bc394 100644 --- a/src/safe_cli/safe_cli.py +++ b/src/safe_cli/safe_cli.py @@ -3,14 +3,12 @@ import sys from typing import Optional -from art import text2art from eth_typing import ChecksumAddress from prompt_toolkit import HTML, PromptSession, print_formatted_text from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.history import FileHistory from prompt_toolkit.lexers import PygmentsLexer -from . import VERSION from .operators import ( SafeCliTerminationException, SafeOperator, @@ -41,8 +39,6 @@ def __init__(self, safe_address: ChecksumAddress, node_url: str, history: bool): self.prompt_parser = PromptParser(self.safe_operator) def print_startup_info(self): - print_formatted_text(text2art("Safe CLI")) # Print fancy text - print_formatted_text(HTML(f"Version: {VERSION}")) print_formatted_text( HTML("Loading Safe information...") ) diff --git a/src/safe_cli/utils.py b/src/safe_cli/utils.py index 7dd7cea3..cceb517d 100644 --- a/src/safe_cli/utils.py +++ b/src/safe_cli/utils.py @@ -89,6 +89,9 @@ def get_safe_from_owner(owner: ChecksumAddress, node_url: str) -> ChecksumAddres :param node_url: :return: Safe address of a selected Safe """ + print_formatted_text( + HTML(f"Loading Safes for {owner}...") + ) ethereum_client = EthereumClient(node_url) safe_tx_service = TransactionServiceApi.from_ethereum_client(ethereum_client) safes = safe_tx_service.get_safes_for_owner(owner) From 4d93c9f8bcbd291bdb30d6a6aefddc6eaf76aa5e Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Tue, 25 Jun 2024 17:51:39 +0200 Subject: [PATCH 12/14] Improve default command validation --- src/safe_cli/main.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/safe_cli/main.py b/src/safe_cli/main.py index 51f69920..b28ea351 100644 --- a/src/safe_cli/main.py +++ b/src/safe_cli/main.py @@ -8,6 +8,7 @@ from hexbytes import HexBytes from prompt_toolkit import HTML, print_formatted_text from typer.main import get_command, get_command_name +from web3 import Web3 from . import VERSION from .argparse_validators import check_hex_str @@ -338,10 +339,25 @@ def default_attended_mode( safe_cli.loop() +def _is_safe_cli_default_command(arguments: List[str]) -> bool: + # safe-cli + if len(sys.argv) == 1: + return True + + if sys.argv[1] == "--help": + return True + + # Only added if is not a valid command, and it is an address. safe-cli 0xaddress http://url + if sys.argv[1] not in [ + get_command_name(key) for key in get_command(app).commands.keys() + ] and Web3.is_checksum_address(sys.argv[1]): + return True + + return False + + def main(): # By default, the attended mode is initialised. Otherwise, the required command must be specified. - if len(sys.argv) == 1 or sys.argv[1] not in [ - get_command_name(key) for key in get_command(app).commands.keys() - ]: + if _is_safe_cli_default_command(sys.argv): sys.argv.insert(1, "attended-mode") app() From f9b5250e796cf5ec69cf377148c31e0e20d674f0 Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Tue, 25 Jun 2024 20:10:38 +0200 Subject: [PATCH 13/14] Add interactive/no-interactive option --- README.md | 10 ++-- src/safe_cli/main.py | 69 +++++++++++++++++++++---- src/safe_cli/operators/safe_operator.py | 14 ++--- tests/test_safe_cli_entry_point.py | 31 +++++++++++ 4 files changed, 104 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3920b080..1b16539e 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,14 @@ usage: To execute transactions unattended you can use: ```bash -safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN -safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN -safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN -safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN +safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN --no-interactive +safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN --no-interactive +safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN --no-interactive +safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN --no-interactive ``` +It is possible to use the environment variable `SAFE_CLI_INTERACTIVE=0` to avoid user interactions. The `--no-interactive` option have more priority than environment variable. + ### Safe-Creator ```bash diff --git a/src/safe_cli/main.py b/src/safe_cli/main.py index b28ea351..98277ced 100644 --- a/src/safe_cli/main.py +++ b/src/safe_cli/main.py @@ -1,4 +1,5 @@ #!/bin/env python3 +import os import sys from typing import Annotated, List @@ -26,13 +27,29 @@ def _build_safe_operator_and_load_keys( - safe_address: ChecksumAddress, node_url: str, private_keys: List[str] + safe_address: ChecksumAddress, + node_url: str, + private_keys: List[str], + interactive: bool, ) -> SafeOperator: - safe_operator = SafeOperator(safe_address, node_url, no_input=True) + safe_operator = SafeOperator(safe_address, node_url, interactive=interactive) safe_operator.load_cli_owners(private_keys) return safe_operator +def _check_interactive_mode(interactive_mode: bool) -> bool: + print(interactive_mode, os.getenv("SAFE_CLI_INTERACTIVE")) + if not interactive_mode: + return False + + # --no-interactive arg > env var. + env_var = os.getenv("SAFE_CLI_INTERACTIVE") + if env_var: + return env_var.lower() in ("true", "1", "yes") + + return True + + @app.command() def send_ether( safe_address: Annotated[ @@ -76,9 +93,17 @@ def send_ether( show_default=False, ), ] = None, + interactive: Annotated[ + bool, + typer.Option( + help="Request iteration from the user. Use --non-interactive for unattended execution.", + rich_help_panel="Optional Arguments", + callback=_check_interactive_mode, + ), + ] = True, ): safe_operator = _build_safe_operator_and_load_keys( - safe_address, node_url, private_key + safe_address, node_url, private_key, interactive ) safe_operator.send_ether(to, value, safe_nonce=safe_nonce) @@ -138,9 +163,17 @@ def send_erc20( show_default=False, ), ] = None, + interactive: Annotated[ + bool, + typer.Option( + help="Request iteration from the user. Use --non-interactive for unattended execution.", + rich_help_panel="Optional Arguments", + callback=_check_interactive_mode, + ), + ] = True, ): safe_operator = _build_safe_operator_and_load_keys( - safe_address, node_url, private_key + safe_address, node_url, private_key, interactive ) safe_operator.send_erc20(to, token_address, amount, safe_nonce=safe_nonce) @@ -197,9 +230,17 @@ def send_erc721( show_default=False, ), ] = None, + interactive: Annotated[ + bool, + typer.Option( + help="Request iteration from the user. Use --non-interactive for unattended execution.", + rich_help_panel="Optional Arguments", + callback=_check_interactive_mode, + ), + ] = True, ): safe_operator = _build_safe_operator_and_load_keys( - safe_address, node_url, private_key + safe_address, node_url, private_key, interactive ) safe_operator.send_erc721(to, token_address, token_id, safe_nonce=safe_nonce) @@ -261,9 +302,17 @@ def send_custom( rich_help_panel="Optional Arguments", ), ] = False, + interactive: Annotated[ + bool, + typer.Option( + help="Request iteration from the user. Use --non-interactive for unattended execution.", + rich_help_panel="Optional Arguments", + callback=_check_interactive_mode, + ), + ] = True, ): safe_operator = _build_safe_operator_and_load_keys( - safe_address, node_url, private_key + safe_address, node_url, private_key, interactive ) safe_operator.send_custom( to, value, data, safe_nonce=safe_nonce, delegate_call=delegate @@ -285,10 +334,10 @@ def version(): safe-cli --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n\n\n\n safe-cli --history 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n safe-cli --history --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n\n\n\n - safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN\n - safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN\n - safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN\n - safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN\n\n\n\n + safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN [--no-interactive]\n + safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN [--no-interactive]\n + safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN [--no-interactive]\n + safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN [--no-interactive]\n\n\n\n """, epilog="Commands available in unattended mode:\n\n\n\n" + "\n\n".join( diff --git a/src/safe_cli/operators/safe_operator.py b/src/safe_cli/operators/safe_operator.py index 80c38e8b..a4104522 100644 --- a/src/safe_cli/operators/safe_operator.py +++ b/src/safe_cli/operators/safe_operator.py @@ -151,9 +151,11 @@ class SafeOperator: executed_transactions: List[str] _safe_cli_info: Optional[SafeCliInfo] require_all_signatures: bool - no_input: bool + interactive: bool - def __init__(self, address: ChecksumAddress, node_url: str, no_input: bool = False): + def __init__( + self, address: ChecksumAddress, node_url: str, interactive: bool = True + ): self.address = address self.node_url = node_url self.ethereum_client = EthereumClient(self.node_url) @@ -184,7 +186,7 @@ def __init__(self, address: ChecksumAddress, node_url: str, no_input: bool = Fal True # Require all signatures to be present to send a tx ) self.hw_wallet_manager = get_hw_wallet_manager() - self.no_input = no_input # Disable prompt dialogs + self.interactive = interactive # Disable prompt dialogs @cached_property def last_default_fallback_handler_address(self) -> ChecksumAddress: @@ -287,7 +289,7 @@ def load_cli_owners(self, keys: List[str]): ) self.default_sender = account except ValueError: - if self.no_input: + if not self.interactive: raise SafeOperatorException(f"Cannot load key={key}") print_formatted_text(HTML(f"Cannot load key={key}")) @@ -941,7 +943,7 @@ def execute_safe_transaction(self, safe_tx: SafeTx): else: call_result = safe_tx.call(self.hw_wallet_manager.sender.address) print_formatted_text(HTML(f"Result: {call_result}")) - if self.no_input or yes_or_no_question( + if not self.interactive or yes_or_no_question( "Do you want to execute tx " + str(safe_tx) ): if self.default_sender: @@ -1002,7 +1004,7 @@ def batch_safe_txs( try: multisend = MultiSend(ethereum_client=self.ethereum_client) except ValueError: - if self.no_input: + if not self.interactive: raise SafeOperatorException( "Multisend contract is not deployed on this network and it's required for batching txs" ) diff --git a/tests/test_safe_cli_entry_point.py b/tests/test_safe_cli_entry_point.py index d3ccbcd7..c4de6507 100644 --- a/tests/test_safe_cli_entry_point.py +++ b/tests/test_safe_cli_entry_point.py @@ -88,6 +88,37 @@ def test_send_ether(self): ) self.assertEqual(result.exit_code, 0) + # Test interactive/non-interactive mode + del os.environ["PYTEST_CURRENT_TEST"] # To avoid skip yes/no question + result = runner.invoke( + app, + [ + "send-ether", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + "20", + "--private-key", + safe_owner.key.hex(), + ], + ) + self.assertEqual(result.exit_code, 1) + + result = runner.invoke( + app, + [ + "send-ether", + safe_operator.safe.address, + "http://localhost:8545", + random_address, + "20", + "--private-key", + safe_owner.key.hex(), + "--no-interactive", + ], + ) + self.assertEqual(result.exit_code, 0) + def test_send_erc20(self): safe_operator = self.setup_operator() safe_owner = Account.create() From 47a619bfe61ba8e114f701904ab90fa5da93d835 Mon Sep 17 00:00:00 2001 From: Felipe Alvarado Date: Wed, 26 Jun 2024 13:24:44 +0200 Subject: [PATCH 14/14] Update common parameters --- README.md | 10 +- src/safe_cli/main.py | 184 +++++++++-------------------- tests/test_safe_cli_entry_point.py | 2 +- 3 files changed, 65 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 1b16539e..2aeac6c9 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,13 @@ usage: To execute transactions unattended you can use: ```bash -safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN --no-interactive -safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN --no-interactive -safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN --no-interactive -safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN --no-interactive +safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN --non-interactive +safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN --non-interactive +safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN --non-interactive +safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN --non-interactive ``` -It is possible to use the environment variable `SAFE_CLI_INTERACTIVE=0` to avoid user interactions. The `--no-interactive` option have more priority than environment variable. +It is possible to use the environment variable `SAFE_CLI_INTERACTIVE=0` to avoid user interactions. The `--non-interactive` option have more priority than environment variable. ### Safe-Creator diff --git a/src/safe_cli/main.py b/src/safe_cli/main.py index 98277ced..081d7b05 100644 --- a/src/safe_cli/main.py +++ b/src/safe_cli/main.py @@ -38,11 +38,10 @@ def _build_safe_operator_and_load_keys( def _check_interactive_mode(interactive_mode: bool) -> bool: - print(interactive_mode, os.getenv("SAFE_CLI_INTERACTIVE")) if not interactive_mode: return False - # --no-interactive arg > env var. + # --non-interactive arg > env var. env_var = os.getenv("SAFE_CLI_INTERACTIVE") if env_var: return env_var.lower() in ("true", "1", "yes") @@ -50,29 +49,48 @@ def _check_interactive_mode(interactive_mode: bool) -> bool: return True +# Common Options +safe_address_option = Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of the Safe.", + callback=check_ethereum_address, + click_type=ChecksumAddressParser(), + show_default=False, + ), +] +node_url_option = Annotated[ + str, typer.Argument(help="Ethereum node url.", show_default=False) +] +to_option = Annotated[ + ChecksumAddress, + typer.Argument( + help="The address of destination.", + callback=check_ethereum_address, + click_type=ChecksumAddressParser(), + show_default=False, + ), +] +interactive_option = Annotated[ + bool, + typer.Option( + "--interactive/--non-interactive", + help=( + "Enable interactive mode to allow user input during execution. " + "Use --non-interactive to disable prompts and run unattended. " + "This is useful for scripting and automation where no user intervention is required." + ), + rich_help_panel="Optional Arguments", + callback=_check_interactive_mode, + ), +] + + @app.command() def send_ether( - safe_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of the Safe.", - callback=check_ethereum_address, - click_type=ChecksumAddressParser(), - show_default=False, - ), - ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], - to: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of destination.", - callback=check_ethereum_address, - click_type=ChecksumAddressParser(), - show_default=False, - ), - ], + safe_address: safe_address_option, + node_url: node_url_option, + to: to_option, value: Annotated[ int, typer.Argument(help="Amount of ether in wei to send.", show_default=False) ], @@ -93,14 +111,7 @@ def send_ether( show_default=False, ), ] = None, - interactive: Annotated[ - bool, - typer.Option( - help="Request iteration from the user. Use --non-interactive for unattended execution.", - rich_help_panel="Optional Arguments", - callback=_check_interactive_mode, - ), - ] = True, + interactive: interactive_option = True, ): safe_operator = _build_safe_operator_and_load_keys( safe_address, node_url, private_key, interactive @@ -110,27 +121,9 @@ def send_ether( @app.command() def send_erc20( - safe_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of the Safe.", - callback=check_ethereum_address, - click_type=ChecksumAddressParser(), - show_default=False, - ), - ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], - to: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of destination.", - callback=check_ethereum_address, - click_type=ChecksumAddressParser(), - show_default=False, - ), - ], + safe_address: safe_address_option, + node_url: node_url_option, + to: to_option, token_address: Annotated[ ChecksumAddress, typer.Argument( @@ -163,14 +156,7 @@ def send_erc20( show_default=False, ), ] = None, - interactive: Annotated[ - bool, - typer.Option( - help="Request iteration from the user. Use --non-interactive for unattended execution.", - rich_help_panel="Optional Arguments", - callback=_check_interactive_mode, - ), - ] = True, + interactive: interactive_option = True, ): safe_operator = _build_safe_operator_and_load_keys( safe_address, node_url, private_key, interactive @@ -180,27 +166,9 @@ def send_erc20( @app.command() def send_erc721( - safe_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of the Safe.", - callback=check_ethereum_address, - click_type=ChecksumAddressParser(), - show_default=False, - ), - ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], - to: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of destination.", - callback=check_ethereum_address, - click_type=ChecksumAddressParser(), - show_default=False, - ), - ], + safe_address: safe_address_option, + node_url: node_url_option, + to: to_option, token_address: Annotated[ ChecksumAddress, typer.Argument( @@ -230,14 +198,7 @@ def send_erc721( show_default=False, ), ] = None, - interactive: Annotated[ - bool, - typer.Option( - help="Request iteration from the user. Use --non-interactive for unattended execution.", - rich_help_panel="Optional Arguments", - callback=_check_interactive_mode, - ), - ] = True, + interactive: interactive_option = True, ): safe_operator = _build_safe_operator_and_load_keys( safe_address, node_url, private_key, interactive @@ -247,27 +208,9 @@ def send_erc721( @app.command() def send_custom( - safe_address: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of the Safe.", - callback=check_ethereum_address, - click_type=ChecksumAddressParser(), - show_default=False, - ), - ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], - to: Annotated[ - ChecksumAddress, - typer.Argument( - help="The address of destination.", - callback=check_ethereum_address, - click_type=ChecksumAddressParser(), - show_default=False, - ), - ], + safe_address: safe_address_option, + node_url: node_url_option, + to: to_option, value: Annotated[int, typer.Argument(help="Value to send.", show_default=False)], data: Annotated[ HexBytes, @@ -302,14 +245,7 @@ def send_custom( rich_help_panel="Optional Arguments", ), ] = False, - interactive: Annotated[ - bool, - typer.Option( - help="Request iteration from the user. Use --non-interactive for unattended execution.", - rich_help_panel="Optional Arguments", - callback=_check_interactive_mode, - ), - ] = True, + interactive: interactive_option = True, ): safe_operator = _build_safe_operator_and_load_keys( safe_address, node_url, private_key, interactive @@ -334,10 +270,10 @@ def version(): safe-cli --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n\n\n\n safe-cli --history 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n safe-cli --history --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n\n\n\n - safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN [--no-interactive]\n - safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN [--no-interactive]\n - safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN [--no-interactive]\n - safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN [--no-interactive]\n\n\n\n + safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN [--non-interactive]\n + safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN [--non-interactive]\n + safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN [--non-interactive]\n + safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN [--non-interactive]\n\n\n\n """, epilog="Commands available in unattended mode:\n\n\n\n" + "\n\n".join( @@ -358,9 +294,7 @@ def default_attended_mode( show_default=False, ), ], - node_url: Annotated[ - str, typer.Argument(help="Ethereum node url.", show_default=False) - ], + node_url: node_url_option, history: Annotated[ bool, typer.Option( diff --git a/tests/test_safe_cli_entry_point.py b/tests/test_safe_cli_entry_point.py index c4de6507..dd86460f 100644 --- a/tests/test_safe_cli_entry_point.py +++ b/tests/test_safe_cli_entry_point.py @@ -114,7 +114,7 @@ def test_send_ether(self): "20", "--private-key", safe_owner.key.hex(), - "--no-interactive", + "--non-interactive", ], ) self.assertEqual(result.exit_code, 0)