diff --git a/README.md b/README.md index 2aeac6c9..c5965a7f 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,12 @@ usage: 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 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] + + safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN [--non-interactive] ╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ * address CHECKSUMADDRESS The address of the Safe, or an owner address if --get-safes-from-owner is specified. [required] │ @@ -75,18 +77,21 @@ 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 you can use: +To execute transactions unattended, or execute transactions from a json exported from the tx_builder 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 --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 + +safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN --non-interactive ``` 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. diff --git a/src/safe_cli/main.py b/src/safe_cli/main.py index 081d7b05..ee8b9b2e 100644 --- a/src/safe_cli/main.py +++ b/src/safe_cli/main.py @@ -1,6 +1,8 @@ #!/bin/env python3 +import json import os import sys +from pathlib import Path from typing import Annotated, List import typer @@ -15,6 +17,7 @@ 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, @@ -255,6 +258,54 @@ def send_custom( ) +@app.command() +def tx_builder( + safe_address: safe_address_option, + node_url: node_url_option, + 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, + interactive: interactive_option = True, +): + safe_operator = _build_safe_operator_and_load_keys( + safe_address, node_url, private_key, interactive + ) + data = json.loads(file_path.read_text()) + safe_txs = [ + safe_operator.prepare_safe_transaction(tx.to, tx.value, tx.data) + for tx in convert_to_proposed_transactions(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]) + else: + 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}") @@ -274,6 +325,7 @@ def version(): 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 + safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/tx-builder/file.json --private-key key1 --private-key keyN [--non-interactive] """, 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 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..12190b41 --- /dev/null +++ b/src/safe_cli/tx_builder/tx_builder_file_decoder.py @@ -0,0 +1,239 @@ +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 .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 is not None + and contract_method_name not in NON_VALID_CONTRACT_METHODS + ) + + if not is_valid_contract_method: + raise InvalidContratMethodError( + f"Invalid contract method {contract_method_name}" + ) + + 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 + ) + + +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 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_cli_entry_point.py b/tests/test_safe_cli_entry_point.py index dd86460f..13af3a94 100644 --- a/tests/test_safe_cli_entry_point.py +++ b/tests/test_safe_cli_entry_point.py @@ -16,7 +16,11 @@ from safe_cli import VERSION from safe_cli.main import app -from safe_cli.operators.exceptions import NotEnoughEtherToSend, SenderRequiredException +from safe_cli.operators.exceptions import ( + NotEnoughEtherToSend, + SafeOperatorException, + SenderRequiredException, +) from safe_cli.safe_cli import SafeCli from .safe_cli_test_case_mixin import SafeCliTestCaseMixin @@ -253,6 +257,57 @@ 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(), + "--non-interactive", + ], + ) + 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 new file mode 100644 index 00000000..5475565c --- /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 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()