diff --git a/docs/web3.contract.rst b/docs/web3.contract.rst index 489a294655..a6531ae99e 100644 --- a/docs/web3.contract.rst +++ b/docs/web3.contract.rst @@ -146,12 +146,29 @@ Each Contract Factory exposes the following properties. The runtime part of the contract bytecode string. May be ``None`` if not provided during factory creation. + +.. py:attribute:: Contract.decode_tuples + + If a Tuple/Struct is returned by a contract function, this flag defines whether + to apply the field names from the ABI to the returned data. + If False, the returned value will be a normal Python `Tuple`. If True, the returned + value will be a Python `NamedTuple` of the class `ABIDecodedNamedTuple`. + + NamedTuples have some restrictions regarding field names. + web3.py sets `NamedTuple`'s `rename=True`, so disallowed field names may be + different than expected. See the [Python docs](https://docs.python.org/3/library/collections.html#collections.namedtuple) + for more information. + + Defaults to ``False`` if not provided during factory creation. + + .. py:attribute:: Contract.functions This provides access to contract functions as attributes. For example: ``myContract.functions.MyMethod()``. The exposed contract functions are classes of the type :py:class:`ContractFunction`. + .. py:attribute:: Contract.events This provides access to contract events as attributes. For example: diff --git a/docs/web3.eth.rst b/docs/web3.eth.rst index 5844875044..e534cb1dfd 100644 --- a/docs/web3.eth.rst +++ b/docs/web3.eth.rst @@ -1454,6 +1454,7 @@ Contracts - ``bytecode_runtime`` - ``clone_bin`` - ``dev_doc`` + - ``decode_tuples`` - ``interface`` - ``metadata`` - ``opcodes`` diff --git a/newsfragments/2799.feature.rst b/newsfragments/2799.feature.rst new file mode 100644 index 0000000000..c6310b2588 --- /dev/null +++ b/newsfragments/2799.feature.rst @@ -0,0 +1 @@ +add decode_tuples option to contract instantiation diff --git a/tests/core/contracts/conftest.py b/tests/core/contracts/conftest.py index fd297e4c1d..834a7f2f5f 100644 --- a/tests/core/contracts/conftest.py +++ b/tests/core/contracts/conftest.py @@ -50,6 +50,10 @@ from web3._utils.contract_sources.contract_data.string_contract import ( STRING_CONTRACT_DATA, ) +from web3._utils.contract_sources.contract_data.tuple_contracts import ( + NESTED_TUPLE_CONTRACT_DATA, + TUPLE_CONTRACT_DATA, +) @pytest.fixture(scope="session") @@ -340,6 +344,44 @@ def revert_contract(w3, address_conversion_func): return deploy(w3, revert_contract_factory, address_conversion_func) +@pytest.fixture +def tuple_contract(w3, address_conversion_func): + tuple_contract_factory = w3.eth.contract(**TUPLE_CONTRACT_DATA) + return deploy(w3, tuple_contract_factory, address_conversion_func) + + +@pytest.fixture +def nested_tuple_contract(w3, address_conversion_func): + nested_tuple_contract_factory = w3.eth.contract(**NESTED_TUPLE_CONTRACT_DATA) + return deploy(w3, nested_tuple_contract_factory, address_conversion_func) + + +TUPLE_CONTRACT_DATA_DECODE_TUPLES = { + **TUPLE_CONTRACT_DATA, + "decode_tuples": True, +} + + +NESTED_TUPLE_CONTRACT_DATA_DECODE_TUPLES = { + **NESTED_TUPLE_CONTRACT_DATA, + "decode_tuples": True, +} + + +@pytest.fixture +def tuple_contract_with_decode_tuples(w3, address_conversion_func): + tuple_contract_factory = w3.eth.contract(**TUPLE_CONTRACT_DATA_DECODE_TUPLES) + return deploy(w3, tuple_contract_factory, address_conversion_func) + + +@pytest.fixture +def nested_tuple_contract_with_decode_tuples(w3, address_conversion_func): + nested_tuple_contract_factory = w3.eth.contract( + **NESTED_TUPLE_CONTRACT_DATA_DECODE_TUPLES + ) + return deploy(w3, nested_tuple_contract_factory, address_conversion_func) + + @pytest.fixture def some_address(address_conversion_func): return address_conversion_func("0x5B2063246F2191f18F2675ceDB8b28102e957458") @@ -563,6 +605,46 @@ async def async_revert_contract(async_w3, address_conversion_func): ) +@pytest_asyncio.fixture +async def async_tuple_contract(async_w3, address_conversion_func): + async_tuple_contract_factory = async_w3.eth.contract(**TUPLE_CONTRACT_DATA) + return await async_deploy( + async_w3, async_tuple_contract_factory, address_conversion_func + ) + + +@pytest_asyncio.fixture +async def async_nested_tuple_contract(async_w3, address_conversion_func): + async_nested_tuple_contract_factory = async_w3.eth.contract( + **NESTED_TUPLE_CONTRACT_DATA + ) + return await async_deploy( + async_w3, async_nested_tuple_contract_factory, address_conversion_func + ) + + +@pytest_asyncio.fixture +async def async_tuple_contract_with_decode_tuples(async_w3, address_conversion_func): + async_tuple_contract_factory = async_w3.eth.contract( + **TUPLE_CONTRACT_DATA_DECODE_TUPLES + ) + return await async_deploy( + async_w3, async_tuple_contract_factory, address_conversion_func + ) + + +@pytest_asyncio.fixture +async def async_nested_tuple_contract_with_decode_tuples( + async_w3, address_conversion_func +): + async_nested_tuple_contract_factory = async_w3.eth.contract( + **NESTED_TUPLE_CONTRACT_DATA_DECODE_TUPLES + ) + return await async_deploy( + async_w3, async_nested_tuple_contract_factory, address_conversion_func + ) + + async def async_invoke_contract( api_call_desig="call", contract=None, diff --git a/tests/core/contracts/test_contract_call_interface.py b/tests/core/contracts/test_contract_call_interface.py index 4236d0766d..e2fda65182 100644 --- a/tests/core/contracts/test_contract_call_interface.py +++ b/tests/core/contracts/test_contract_call_interface.py @@ -20,14 +20,13 @@ async_deploy, deploy, ) +from web3._utils.abi import ( + recursive_dict_to_namedtuple, +) from web3._utils.contract_sources.contract_data.bytes_contracts import ( BYTES32_CONTRACT_DATA, BYTES_CONTRACT_DATA, ) -from web3._utils.contract_sources.contract_data.tuple_contracts import ( - NESTED_TUPLE_CONTRACT_DATA, - TUPLE_CONTRACT_DATA, -) from web3._utils.ens import ( contract_ens_addresses, ) @@ -48,18 +47,6 @@ ) -@pytest.fixture -def tuple_contract(w3, address_conversion_func): - tuple_contract_factory = w3.eth.contract(**TUPLE_CONTRACT_DATA) - return deploy(w3, tuple_contract_factory, address_conversion_func) - - -@pytest.fixture -def nested_tuple_contract(w3, address_conversion_func): - nested_tuple_contract_factory = w3.eth.contract(**NESTED_TUPLE_CONTRACT_DATA) - return deploy(w3, nested_tuple_contract_factory, address_conversion_func) - - @pytest.fixture(params=[b"\x04\x06", "0x0406"]) def bytes_contract(w3, request, address_conversion_func): bytes_contract_factory = w3.eth.contract(**BYTES_CONTRACT_DATA) @@ -902,6 +889,84 @@ def test_call_tuple_contract(tuple_contract, method_input, expected): assert result == expected +@pytest.mark.parametrize( + "method_input, plain_tuple_output, type_str, namedtuple_repr", + ( + ( + { + "a": 123, + "b": [1, 2], + "c": [ + { + "x": 234, + "y": [True, False], + "z": [ + "0x4AD7E79d88650B01EEA2B1f069f01EE9db343d5c", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + ], + }, + { + "x": 345, + "y": [False, False], + "z": [ + "0xefd1FF70c185A1C0b125939815225199079096Ee", + "0xf35C0784794F3Cd935F5754d3a0EbcE95bEf851e", + ], + }, + ], + }, + ( + 123, + [1, 2], + [ + ( + 234, + [True, False], + [ + "0x4AD7E79d88650B01EEA2B1f069f01EE9db343d5c", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + ], + ), + ( + 345, + [False, False], + [ + "0xefd1FF70c185A1C0b125939815225199079096Ee", + "0xf35C0784794F3Cd935F5754d3a0EbcE95bEf851e", + ], + ), + ], + ), + ".ABIDecodedNamedTuple'>", # noqa: E501 + "ABIDecodedNamedTuple(a=123, b=[1, 2], c=[ABIDecodedNamedTuple(x=234, y=[True, False], z=['0x4AD7E79d88650B01EEA2B1f069f01EE9db343d5c', '0xfdF1946A9b40245224488F1a36f4A9ed4844a523', '0xfdF1946A9b40245224488F1a36f4A9ed4844a523']), ABIDecodedNamedTuple(x=345, y=[False, False], z=['0xefd1FF70c185A1C0b125939815225199079096Ee', '0xf35C0784794F3Cd935F5754d3a0EbcE95bEf851e'])])", # noqa: E501 + ), + ), +) +def test_call_tuple_contract_with_decode_tuples_set( + tuple_contract_with_decode_tuples, + method_input, + plain_tuple_output, + type_str, + namedtuple_repr, +): + result = tuple_contract_with_decode_tuples.functions.method(method_input).call() + + # check contract output matches dict_to_namedtuple output + namedtuple_from_input = recursive_dict_to_namedtuple(method_input) + assert result == namedtuple_from_input + assert str(type(result)) == type_str + assert result.__repr__() == namedtuple_repr + + # check that the namedtuple ouput is still a tuple + assert result == plain_tuple_output + + # check that fields are correct + assert result._fields == ("a", "b", "c") + assert result.c[0]._fields == ("x", "y", "z") + + @pytest.mark.parametrize( "method_input, expected", ( @@ -988,6 +1053,75 @@ def test_call_nested_tuple_contract(nested_tuple_contract, method_input, expecte assert result == expected +@pytest.mark.parametrize( + "method_input, plain_tuple_output, type_str, namedtuple_repr", + ( + ( + { + "t": [ + { + "u": [ + {"x": 1, "y": 2}, + {"x": 3, "y": 4}, + {"x": 5, "y": 6}, + ] + }, + { + "u": [ + {"x": 7, "y": 8}, + {"x": 9, "y": 10}, + {"x": 11, "y": 12}, + ] + }, + ] + }, + ( + [ + ( + [ + (1, 2), + (3, 4), + (5, 6), + ], + ), + ( + [ + (7, 8), + (9, 10), + (11, 12), + ], + ), + ], + ), + ".ABIDecodedNamedTuple'>", # noqa: E501 + "ABIDecodedNamedTuple(t=[ABIDecodedNamedTuple(u=[ABIDecodedNamedTuple(x=1, y=2), ABIDecodedNamedTuple(x=3, y=4), ABIDecodedNamedTuple(x=5, y=6)]), ABIDecodedNamedTuple(u=[ABIDecodedNamedTuple(x=7, y=8), ABIDecodedNamedTuple(x=9, y=10), ABIDecodedNamedTuple(x=11, y=12)])])", # noqa: E501 + ), + ), +) +def test_call_nested_tuple_contract_with_decode_tuples_set( + nested_tuple_contract_with_decode_tuples, + method_input, + plain_tuple_output, + type_str, + namedtuple_repr, +): + result = nested_tuple_contract_with_decode_tuples.functions.method( + method_input + ).call() + # check contract output matches dict_to_namedtuple output + namedtuple_from_input = recursive_dict_to_namedtuple(method_input) + assert result == namedtuple_from_input + assert str(type(result)) == type_str + assert result.__repr__() == namedtuple_repr + + # check that the namedtuple ouput is still a tuple + assert result == plain_tuple_output + + # check that fields are correct + assert result._fields == ("t",) + assert result.t[0]._fields == ("u",) + + def test_call_revert_contract(revert_contract): with pytest.raises(TransactionFailed, match="Function has been reverted."): # eth-tester will do a gas estimation if we don't submit a gas value, @@ -1013,24 +1147,6 @@ def test_changing_default_block_identifier(w3, math_contract): # -- async -- # -@pytest_asyncio.fixture -async def async_tuple_contract(async_w3, address_conversion_func): - async_tuple_contract_factory = async_w3.eth.contract(**TUPLE_CONTRACT_DATA) - return await async_deploy( - async_w3, async_tuple_contract_factory, address_conversion_func - ) - - -@pytest_asyncio.fixture -async def async_nested_tuple_contract(async_w3, address_conversion_func): - async_nested_tuple_contract_factory = async_w3.eth.contract( - **NESTED_TUPLE_CONTRACT_DATA - ) - return await async_deploy( - async_w3, async_nested_tuple_contract_factory, address_conversion_func - ) - - @pytest.fixture def async_bytes_contract_factory(async_w3): return async_w3.eth.contract(**BYTES_CONTRACT_DATA) @@ -1887,6 +2003,87 @@ async def test_async_call_tuple_contract(async_tuple_contract, method_input, exp assert result == expected +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method_input, plain_tuple_output, type_str, namedtuple_repr", + ( + ( + { + "a": 123, + "b": [1, 2], + "c": [ + { + "x": 234, + "y": [True, False], + "z": [ + "0x4AD7E79d88650B01EEA2B1f069f01EE9db343d5c", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + ], + }, + { + "x": 345, + "y": [False, False], + "z": [ + "0xefd1FF70c185A1C0b125939815225199079096Ee", + "0xf35C0784794F3Cd935F5754d3a0EbcE95bEf851e", + ], + }, + ], + }, + ( + 123, + [1, 2], + [ + ( + 234, + [True, False], + [ + "0x4AD7E79d88650B01EEA2B1f069f01EE9db343d5c", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + ], + ), + ( + 345, + [False, False], + [ + "0xefd1FF70c185A1C0b125939815225199079096Ee", + "0xf35C0784794F3Cd935F5754d3a0EbcE95bEf851e", + ], + ), + ], + ), + ".ABIDecodedNamedTuple'>", # noqa: E501 + "ABIDecodedNamedTuple(a=123, b=[1, 2], c=[ABIDecodedNamedTuple(x=234, y=[True, False], z=['0x4AD7E79d88650B01EEA2B1f069f01EE9db343d5c', '0xfdF1946A9b40245224488F1a36f4A9ed4844a523', '0xfdF1946A9b40245224488F1a36f4A9ed4844a523']), ABIDecodedNamedTuple(x=345, y=[False, False], z=['0xefd1FF70c185A1C0b125939815225199079096Ee', '0xf35C0784794F3Cd935F5754d3a0EbcE95bEf851e'])])", # noqa: E501 + ), + ), +) +async def test_async_call_tuple_contract_with_decode_tuples_set( + async_tuple_contract_with_decode_tuples, + method_input, + plain_tuple_output, + type_str, + namedtuple_repr, +): + result = await async_tuple_contract_with_decode_tuples.functions.method( + method_input + ).call() + + # check contract output matches dict_to_namedtuple output + namedtuple_from_input = recursive_dict_to_namedtuple(method_input) + assert result == namedtuple_from_input + assert str(type(result)) == type_str + assert result.__repr__() == namedtuple_repr + + # check that the namedtuple ouput is still a tuple + assert result == plain_tuple_output + + # check that fields are correct + assert result._fields == ("a", "b", "c") + assert result.c[0]._fields == ("x", "y", "z") + + @pytest.mark.asyncio @pytest.mark.parametrize( "method_input, expected", @@ -1976,6 +2173,77 @@ async def test_async_call_nested_tuple_contract( assert result == expected +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method_input, plain_tuple_output, type_str, namedtuple_repr", + ( + ( + { + "t": [ + { + "u": [ + {"x": 1, "y": 2}, + {"x": 3, "y": 4}, + {"x": 5, "y": 6}, + ] + }, + { + "u": [ + {"x": 7, "y": 8}, + {"x": 9, "y": 10}, + {"x": 11, "y": 12}, + ] + }, + ] + }, + ( + [ + ( + [ + (1, 2), + (3, 4), + (5, 6), + ], + ), + ( + [ + (7, 8), + (9, 10), + (11, 12), + ], + ), + ], + ), + ".ABIDecodedNamedTuple'>", # noqa: E501 + "ABIDecodedNamedTuple(t=[ABIDecodedNamedTuple(u=[ABIDecodedNamedTuple(x=1, y=2), ABIDecodedNamedTuple(x=3, y=4), ABIDecodedNamedTuple(x=5, y=6)]), ABIDecodedNamedTuple(u=[ABIDecodedNamedTuple(x=7, y=8), ABIDecodedNamedTuple(x=9, y=10), ABIDecodedNamedTuple(x=11, y=12)])])", # noqa: E501 + ), + ), +) +async def test_async_call_nested_tuple_contract_with_decode_tuples_set( + async_nested_tuple_contract_with_decode_tuples, + method_input, + plain_tuple_output, + type_str, + namedtuple_repr, +): + result = await async_nested_tuple_contract_with_decode_tuples.functions.method( + method_input + ).call() + + # check contract output matches dict_to_namedtuple output + namedtuple_from_input = recursive_dict_to_namedtuple(method_input) + assert result == namedtuple_from_input + assert str(type(result)) == type_str + assert result.__repr__() == namedtuple_repr + + # check that the namedtuple ouput is still a tuple + assert result == plain_tuple_output + + # check that fields are correct + assert result._fields == ("t",) + assert result.t[0]._fields == ("u",) + + @pytest.mark.asyncio async def test_async_call_revert_contract(async_revert_contract): with pytest.raises(TransactionFailed, match="Function has been reverted."): diff --git a/tests/core/contracts/test_contract_caller_interface.py b/tests/core/contracts/test_contract_caller_interface.py index a644f3b081..046aa384ab 100644 --- a/tests/core/contracts/test_contract_caller_interface.py +++ b/tests/core/contracts/test_contract_caller_interface.py @@ -23,6 +23,63 @@ def transaction_dict(w3, address): } +decode_tuples_args = ( + "method_input, tuple_output, type_str, namedtuple_repr", + ( + ( + { + "a": 123, + "b": [1, 2], + "c": [ + { + "x": 234, + "y": [True, False], + "z": [ + "0x4AD7E79d88650B01EEA2B1f069f01EE9db343d5c", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + ], + }, + { + "x": 345, + "y": [False, False], + "z": [ + "0xefd1FF70c185A1C0b125939815225199079096Ee", + "0xf35C0784794F3Cd935F5754d3a0EbcE95bEf851e", + ], + }, + ], + }, + ( + 123, + [1, 2], + [ + ( + 234, + [True, False], + [ + "0x4AD7E79d88650B01EEA2B1f069f01EE9db343d5c", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + "0xfdF1946A9b40245224488F1a36f4A9ed4844a523", + ], + ), + ( + 345, + [False, False], + [ + "0xefd1FF70c185A1C0b125939815225199079096Ee", + "0xf35C0784794F3Cd935F5754d3a0EbcE95bEf851e", + ], + ), + ], + ), + ".ABIDecodedNamedTuple'>", # noqa: E501 + "ABIDecodedNamedTuple(a=123, b=[1, 2], c=[ABIDecodedNamedTuple(x=234, y=[True, False], z=['0x4AD7E79d88650B01EEA2B1f069f01EE9db343d5c', '0xfdF1946A9b40245224488F1a36f4A9ed4844a523', '0xfdF1946A9b40245224488F1a36f4A9ed4844a523']), ABIDecodedNamedTuple(x=345, y=[False, False], z=['0xefd1FF70c185A1C0b125939815225199079096Ee', '0xf35C0784794F3Cd935F5754d3a0EbcE95bEf851e'])])", # noqa: E501 + ), + ), +) + + def test_caller_default(math_contract): result = math_contract.caller.add(3, 5) assert result == 8 @@ -151,6 +208,37 @@ def test_caller_with_args_and_no_transaction_keyword( assert add_result == 8 +@pytest.mark.parametrize(*decode_tuples_args) +def test_tuple_contract_caller_default_with_decode_tuples( + tuple_contract_with_decode_tuples, + method_input, + tuple_output, + type_str, + namedtuple_repr, +): + result = tuple_contract_with_decode_tuples.caller.method(method_input) + assert result == tuple_output + assert str(type(result)) == type_str + assert result.__repr__() == namedtuple_repr + + +@pytest.mark.parametrize(*decode_tuples_args) +def test_tuple_contract_caller_with_parens_with_decode_tuples( + tuple_contract_with_decode_tuples, + method_input, + tuple_output, + type_str, + namedtuple_repr, +): + result = tuple_contract_with_decode_tuples.caller().method(method_input) + assert result == tuple_output + assert str(type(result)) == type_str + assert result.__repr__() == namedtuple_repr + + +# --- async --- # + + @pytest.mark.asyncio async def test_async_caller_default(async_math_contract): result = await async_math_contract.caller.add(3, 5) @@ -295,3 +383,33 @@ async def test_async_caller_with_args_and_no_transaction_keyword( add_result = await contract.add(3, 5) assert add_result == 8 + + +@pytest.mark.parametrize(*decode_tuples_args) +@pytest.mark.asyncio +async def test_async_tuple_contract_caller_default_with_decode_tuples( + async_tuple_contract_with_decode_tuples, + method_input, + tuple_output, + type_str, + namedtuple_repr, +): + result = await async_tuple_contract_with_decode_tuples.caller.method(method_input) + assert result == tuple_output + assert str(type(result)) == type_str + assert result.__repr__() == namedtuple_repr + + +@pytest.mark.parametrize(*decode_tuples_args) +@pytest.mark.asyncio +async def test_async_tuple_contract_caller_with_parens_with_decode_tuples( + async_tuple_contract_with_decode_tuples, + method_input, + tuple_output, + type_str, + namedtuple_repr, +): + result = await async_tuple_contract_with_decode_tuples.caller().method(method_input) + assert result == tuple_output + assert str(type(result)) == type_str + assert result.__repr__() == namedtuple_repr diff --git a/tests/core/contracts/test_contract_method_abi_decoding.py b/tests/core/contracts/test_contract_method_abi_decoding.py index bc54d4bca6..bc61dc6579 100644 --- a/tests/core/contracts/test_contract_method_abi_decoding.py +++ b/tests/core/contracts/test_contract_method_abi_decoding.py @@ -87,12 +87,15 @@ "0xc29a4b71000000000000000000000000bfae42a79ff045659dd0f84e65534f5c4c8100230000000000000000000000000000000000000000000000000000000000000000000000000000000000000000db3d3af153cb02f0bc44621db82289280e93500f94a7d1598c397f6b49ecd5ccbc2b464259b96870063493b0dc7409d0fd9fb9860000000000000000000000000000000000000000000000000429d069189e00000000000000000000000000000000000178287f49c4a1d6622fb2ab40000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001", # noqa: E501 "liquidate", { - "fromAccount": ("0xBfae42A79FF045659DD0F84e65534f5c4c810023", 0), - "liquidAccount": ( - "0xdb3d3AF153cB02f0Bc44621Db82289280e93500F", - 67238809929330522294664880975001390268660278453875034113630810005818923006342, # noqa: E501 - ), # noqa: E501 - "minLiquidatorRatio": (300000000000000000,), + "fromAccount": { + "owner": "0xBfae42A79FF045659DD0F84e65534f5c4c810023", + "number": 0, + }, + "liquidAccount": { + "owner": "0xdb3d3AF153cB02f0Bc44621Db82289280e93500F", + "number": 67238809929330522294664880975001390268660278453875034113630810005818923006342, # noqa: E501 + }, + "minLiquidatorRatio": {"value": 300000000000000000}, "minValueLiquidated": 500000000000000000000000000000000000000, "owedPreferences": [0, 1, 2], "heldPreferences": [2, 0, 1], diff --git a/tests/core/contracts/test_extracting_event_data.py b/tests/core/contracts/test_extracting_event_data.py index d68aa65311..07e113dbf4 100644 --- a/tests/core/contracts/test_extracting_event_data.py +++ b/tests/core/contracts/test_extracting_event_data.py @@ -530,7 +530,7 @@ def test_argument_extraction_strict_bytes_types( "logStruct", "LogStructArgs", [1, (2, 3, (4,))], - {"arg0": 1, "arg1": (2, 3, (4,))}, + {"arg0": 1, "arg1": {"a": 2, "b": 3, "nested": {"c": 4}}}, "The event signature did not match the provided ABI", True, ), @@ -538,7 +538,7 @@ def test_argument_extraction_strict_bytes_types( "logStruct", "LogStructArgs", [1, (2, 3, (4,))], - {"arg0": 1, "arg1": (2, 3, (4,))}, + {"arg0": 1, "arg1": {"a": 2, "b": 3, "nested": {"c": 4}}}, "The event signature did not match the provided ABI", False, ), @@ -793,7 +793,7 @@ def test_event_rich_log( "logStruct", "LogStructArgs", [1, (2, 3, (4,))], - {"arg0": 1, "arg1": (2, 3, (4,))}, + {"arg0": 1, "arg1": {"a": 2, "b": 3, "nested": {"c": 4}}}, "The event signature did not match the provided ABI", True, ), @@ -801,7 +801,7 @@ def test_event_rich_log( "logStruct", "LogStructArgs", [1, (2, 3, (4,))], - {"arg0": 1, "arg1": (2, 3, (4,))}, + {"arg0": 1, "arg1": {"a": 2, "b": 3, "nested": {"c": 4}}}, "The event signature did not match the provided ABI", False, ), @@ -976,7 +976,7 @@ def test_get_all_entries_with_nested_tuple_event(w3, emitter): log_entry = entries[0] - assert log_entry.args == {"arg0": 1, "arg1": (2, 3, (4,))} + assert log_entry.args == {"arg0": 1, "arg1": {"a": 2, "b": 3, "nested": {"c": 4}}} assert log_entry.event == "LogStructArgs" assert log_entry.blockHash == txn_receipt["blockHash"] assert log_entry.blockNumber == txn_receipt["blockNumber"] @@ -1002,7 +1002,7 @@ def test_get_all_entries_with_nested_tuple_event_non_strict( log_entry = entries[0] - assert log_entry.args == {"arg0": 1, "arg1": (2, 3, (4,))} + assert log_entry.args == {"arg0": 1, "arg1": {"a": 2, "b": 3, "nested": {"c": 4}}} assert log_entry.event == "LogStructArgs" assert log_entry.blockHash == txn_receipt["blockHash"] assert log_entry.blockNumber == txn_receipt["blockNumber"] diff --git a/tests/core/utilities/test_abi_named_tree.py b/tests/core/utilities/test_abi_named_tree.py new file mode 100644 index 0000000000..85efa40f38 --- /dev/null +++ b/tests/core/utilities/test_abi_named_tree.py @@ -0,0 +1,107 @@ +import pytest + +from eth_abi.codec import ( + ABICodec, +) +from eth_abi.registry import ( + registry as default_registry, +) + +from web3._utils.abi import ( + abi_decoded_namedtuple_factory, + check_if_arguments_can_be_encoded, + named_tree, + recursive_dict_to_namedtuple, +) +from web3.exceptions import ( + MismatchedABI, +) + +from .test_abi import ( + TEST_FUNCTION_ABI, +) + +full_abi_inputs = TEST_FUNCTION_ABI["inputs"] +full_values = ( + (1, [2, 3, 4], [(5, 6), (7, 8), (9, 10)]), # Value for s + (11, 12), # Value for t + 13, # Value for a + [[(14, 15), (16, 17)], [(18, 19)]], # Value for b +) + + +def test_named_arguments_decode(): + decoded = named_tree(full_abi_inputs, full_values) + data = recursive_dict_to_namedtuple(decoded) + assert data == full_values + assert data.s.c[2].y == 10 + assert data.t.x == 11 + assert data.a == 13 + + +short_abi_inputs_with_disallowed_names = [ + { + "components": [ + {"name": "from", "type": "uint256"}, + {"name": "to", "type": "uint256[]"}, + { + "components": [ + {"name": "_x", "type": "uint256"}, + {"name": "_y", "type": "uint256"}, + ], + "name": "c", + "type": "tuple[]", + }, + ], + "name": "s", + "type": "tuple", + }, +] + +short_values = ((1, [2, 3, 4], [(5, 6), (7, 8), (9, 10)]),) + + +def test_named_arguments_decode_rename(): + decoded = named_tree(short_abi_inputs_with_disallowed_names, short_values) + data = recursive_dict_to_namedtuple(decoded) + assert data == short_values + assert data._fields == ("s",) + + # python keyword "from" renamed to "_0" + assert data.s._fields == ("_0", "to", "c") + + # field names starting with "_" - "_x" and "_y" - renamed to "_0" and "_1" + assert data.s.c[0]._fields == ("_0", "_1") + assert data.s.c[2]._1 == 10 + assert data.s.to[1] == 3 + + +@pytest.mark.parametrize( + "values", + ( + ((1, [2, 3, 4], [(5,), (7, 8), (9, 10)]),), + ((1, [2, 3, 4], [(5, 6, 11), (7, 8), (9, 10)]),), + ((1, [(5, 6), (7, 8), (9, 10)]),), + ), +) +def test_named_arguments_decode_with_misshapen_inputs(values): + with pytest.raises(MismatchedABI): + named_tree(short_abi_inputs_with_disallowed_names, values) + + +def test_namedtuples_encodable(): + registry = default_registry.copy() + codec = ABICodec(registry) + kwargs = named_tree(full_abi_inputs, full_values) + args = recursive_dict_to_namedtuple(kwargs) + assert check_if_arguments_can_be_encoded(TEST_FUNCTION_ABI, codec, (), kwargs) + assert check_if_arguments_can_be_encoded(TEST_FUNCTION_ABI, codec, args, {}) + + +def test_ABIDecodedNamedTuple(): + item = abi_decoded_namedtuple_factory(["a", "b", "c"])([1, 2, 3]) + assert type(item)(item) == item == (1, 2, 3) + assert item.c == 3 + + expected_asdict = {"a": 1, "b": 2, "c": 3} + assert item._asdict() == expected_asdict diff --git a/web3/_utils/abi.py b/web3/_utils/abi.py index 9a39b6e8d6..22687de849 100644 --- a/web3/_utils/abi.py +++ b/web3/_utils/abi.py @@ -74,6 +74,7 @@ ) from web3.exceptions import ( FallbackNotFound, + MismatchedABI, ) from web3.types import ( ABI, @@ -906,3 +907,67 @@ def build_strict_registry() -> ABIRegistry: label="string", ) return registry + + +def named_tree( + abi: Iterable[Union[ABIFunctionParams, ABIFunction, ABIEvent, Dict[TypeStr, Any]]], + data: Iterable[Tuple[Any, ...]], +) -> Dict[str, Any]: + """ + Convert function inputs/outputs or event data tuple to dict with names from ABI. + """ + names = [item["name"] for item in abi] + items = [_named_subtree(*item) for item in zip(abi, data)] + + return dict(zip(names, items)) + + +def _named_subtree( + abi: Union[ABIFunctionParams, ABIFunction, ABIEvent, Dict[TypeStr, Any]], + data: Tuple[Any, ...], +) -> Union[Dict[str, Any], Tuple[Any, ...], List[Any]]: + abi_type = parse(collapse_if_tuple(dict(abi))) + + if abi_type.is_array: + item_type = abi_type.item_type.to_type_str() + item_abi = {**abi, "type": item_type, "name": ""} + items = [_named_subtree(item_abi, item) for item in data] + return items + + elif isinstance(abi_type, TupleType): + abi = cast(ABIFunctionParams, abi) + names = [item["name"] for item in abi["components"]] + items = [_named_subtree(*item) for item in zip(abi["components"], data)] + + if len(names) == len(data): + return dict(zip(names, items)) + else: + raise MismatchedABI( + f"ABI fields {names} has length {len(names)} but received " + f"data {data} with length {len(data)}" + ) + + return data + + +def recursive_dict_to_namedtuple(data: Dict[str, Any]) -> Tuple[Any, ...]: + def _dict_to_namedtuple( + value: Union[Dict[str, Any], List[Any]] + ) -> Union[Tuple[Any, ...], List[Any]]: + if not isinstance(value, dict): + return value + + keys, values = zip(*value.items()) + return abi_decoded_namedtuple_factory(keys)(values) + + return recursive_map(_dict_to_namedtuple, data) + + +def abi_decoded_namedtuple_factory( + fields: Tuple[Any, ...] +) -> Callable[..., Tuple[Any, ...]]: + class ABIDecodedNamedTuple(namedtuple("ABIDecodedNamedTuple", fields, rename=True)): # type: ignore # noqa: E501 + def __new__(self, args: Any) -> "ABIDecodedNamedTuple": + return super().__new__(self, *args) + + return ABIDecodedNamedTuple diff --git a/web3/_utils/contracts.py b/web3/_utils/contracts.py index 61ee9315fb..2d1b7a2b48 100644 --- a/web3/_utils/contracts.py +++ b/web3/_utils/contracts.py @@ -2,6 +2,8 @@ from typing import ( TYPE_CHECKING, Any, + Callable, + Dict, Optional, Sequence, Tuple, @@ -13,9 +15,13 @@ from eth_abi.codec import ( ABICodec, ) +from eth_abi.registry import ( + registry as default_registry, +) from eth_typing import ( ChecksumAddress, HexStr, + TypeStr, ) from eth_utils import ( add_0x_prefix, @@ -47,6 +53,7 @@ get_receive_func_abi, map_abi_data, merge_args_and_kwargs, + named_tree, ) from web3._utils.blocks import ( is_hex_encoded_block_hash, @@ -312,6 +319,21 @@ def encode_transaction_data( return add_0x_prefix(encode_abi(w3, fn_abi, fn_arguments, fn_selector)) +def decode_transaction_data( + fn_abi: ABIFunction, + data: HexStr, + normalizers: Sequence[Callable[[TypeStr, Any], Tuple[TypeStr, Any]]] = None, +) -> Dict[str, Any]: + # type ignored b/c expects data arg to be HexBytes + data = HexBytes(data) # type: ignore + types = get_abi_input_types(fn_abi) + abi_codec = ABICodec(default_registry) + decoded = abi_codec.decode(types, HexBytes(data[4:])) + if normalizers: + decoded = map_abi_data(normalizers, types, decoded) + return named_tree(fn_abi["inputs"], decoded) + + def get_fallback_function_info( contract_abi: Optional[ABI] = None, fn_abi: Optional[ABIFunction] = None ) -> Tuple[ABIFunction, HexStr, Tuple[Any, ...]]: diff --git a/web3/_utils/events.py b/web3/_utils/events.py index c8632dd19a..f63360cd5c 100644 --- a/web3/_utils/events.py +++ b/web3/_utils/events.py @@ -58,6 +58,7 @@ get_indexed_event_inputs, get_normalized_abi_arg_type, map_abi_data, + named_tree, normalize_event_input_types, ) from web3._utils.encoding import ( @@ -255,6 +256,10 @@ def get_event_data( normalized_log_data = map_abi_data( BASE_RETURN_NORMALIZERS, log_data_types, decoded_log_data ) + named_log_data = named_tree( + log_data_normalized_inputs, + normalized_log_data, + ) decoded_topic_data = [ abi_codec.decode([topic_type], topic_data)[0] @@ -267,7 +272,7 @@ def get_event_data( event_args = dict( itertools.chain( zip(log_topic_names, normalized_topic_data), - zip(log_data_names, normalized_log_data), + named_log_data.items(), ) ) diff --git a/web3/contract/async_contract.py b/web3/contract/async_contract.py index d71d21022e..97aa815c10 100644 --- a/web3/contract/async_contract.py +++ b/web3/contract/async_contract.py @@ -79,8 +79,9 @@ def __init__( abi: ABI, w3: "Web3", address: Optional[ChecksumAddress] = None, + decode_tuples: Optional[bool] = False, ) -> None: - super().__init__(abi, w3, AsyncContractFunction, address) + super().__init__(abi, w3, AsyncContractFunction, address, decode_tuples) class AsyncContractEvents(BaseContractEvents): @@ -115,8 +116,12 @@ def __init__(self, address: Optional[ChecksumAddress] = None) -> None: raise TypeError( "The address argument is required to instantiate a contract." ) - self.functions = AsyncContractFunctions(self.abi, self.w3, self.address) - self.caller = AsyncContractCaller(self.abi, self.w3, self.address) + self.functions = AsyncContractFunctions( + self.abi, self.w3, self.address, decode_tuples=self.decode_tuples + ) + self.caller = AsyncContractCaller( + self.abi, self.w3, self.address, decode_tuples=self.decode_tuples + ) self.events = AsyncContractEvents(self.abi, self.w3, self.address) self.fallback = AsyncContract.get_fallback_function( self.abi, self.w3, AsyncContractFunction, self.address @@ -147,9 +152,14 @@ def factory( normalizers=normalizers, ), ) - contract.functions = AsyncContractFunctions(contract.abi, contract.w3) + contract.functions = AsyncContractFunctions( + contract.abi, contract.w3, decode_tuples=contract.decode_tuples + ) contract.caller = AsyncContractCaller( - contract.abi, contract.w3, contract.address + contract.abi, + contract.w3, + contract.address, + decode_tuples=contract.decode_tuples, ) contract.events = AsyncContractEvents(contract.abi, contract.w3) contract.fallback = AsyncContract.get_fallback_function( @@ -289,6 +299,7 @@ async def call( self.abi, state_override, ccip_read_enabled, + self.decode_tuples, *self.args, **self.kwargs, ) @@ -468,6 +479,7 @@ def __init__( transaction: Optional[TxParams] = None, block_identifier: BlockIdentifier = "latest", ccip_read_enabled: Optional[bool] = None, + decode_tuples: Optional[bool] = False, ) -> None: super().__init__( abi=abi, @@ -477,6 +489,7 @@ def __init__( block_identifier=block_identifier, ccip_read_enabled=ccip_read_enabled, contract_function_class=AsyncContractFunction, + decode_tuples=decode_tuples, ) def __call__( @@ -494,4 +507,5 @@ def __call__( transaction=transaction, block_identifier=block_identifier, ccip_read_enabled=ccip_read_enabled, + decode_tuples=self.decode_tuples, ) diff --git a/web3/contract/base_contract.py b/web3/contract/base_contract.py index 89c9622a43..37a641193b 100644 --- a/web3/contract/base_contract.py +++ b/web3/contract/base_contract.py @@ -43,15 +43,13 @@ check_if_arguments_can_be_encoded, fallback_func_abi_exists, filter_by_type, - get_abi_input_names, - get_abi_input_types, get_constructor_abi, is_array_type, - map_abi_data, merge_args_and_kwargs, receive_func_abi_exists, ) from web3._utils.contracts import ( + decode_transaction_data, encode_abi, find_matching_event_abi, find_matching_fn_abi, @@ -159,6 +157,7 @@ class BaseContract: bytecode_runtime = None clone_bin = None + decode_tuples = None dev_doc = None interface = None metadata = None @@ -254,16 +253,11 @@ def decode_function_input( ) -> Tuple["BaseContractFunction", Dict[str, Any]]: # type ignored b/c expects data arg to be HexBytes data = HexBytes(data) # type: ignore - selector, params = data[:4], data[4:] - func = self.get_function_by_selector(selector) - - names = get_abi_input_names(func.abi) - types = get_abi_input_types(func.abi) - - decoded = self.w3.codec.decode(types, cast(HexBytes, params)) - normalized = map_abi_data(BASE_RETURN_NORMALIZERS, types, decoded) - - return func, dict(zip(names, normalized)) + func = self.get_function_by_selector(data[:4]) + arguments = decode_transaction_data( + func.abi, data, normalizers=BASE_RETURN_NORMALIZERS + ) + return func, arguments @combomethod def find_functions_by_args(self, *args: Any) -> "BaseContractFunction": @@ -435,6 +429,7 @@ def __init__( w3: "Web3", contract_function_class: Type["BaseContractFunction"], address: Optional[ChecksumAddress] = None, + decode_tuples: Optional[bool] = False, ) -> None: self.abi = abi self.w3 = w3 @@ -451,6 +446,7 @@ def __init__( w3=self.w3, contract_abi=self.abi, address=self.address, + decode_tuples=decode_tuples, function_identifier=func["name"], ), ) @@ -668,6 +664,7 @@ class BaseContractFunction: abi: ABIFunction = None transaction: TxParams = None arguments: Tuple[Any, ...] = None + decode_tuples: Optional[bool] = False args: Any = None kwargs: Any = None @@ -1086,11 +1083,13 @@ def __init__( transaction: Optional[TxParams] = None, block_identifier: BlockIdentifier = "latest", ccip_read_enabled: Optional[bool] = None, + decode_tuples: Optional[bool] = False, ) -> None: self.w3 = w3 self.address = address self.abi = abi self._functions = None + self.decode_tuples = decode_tuples if self.abi: if transaction is None: @@ -1104,6 +1103,7 @@ def __init__( contract_abi=self.abi, address=self.address, function_identifier=func["name"], + decode_tuples=decode_tuples, ) block_id = parse_block_identifier(self.w3, block_identifier) diff --git a/web3/contract/contract.py b/web3/contract/contract.py index 0e64885a3d..4b95dbcc6f 100644 --- a/web3/contract/contract.py +++ b/web3/contract/contract.py @@ -79,8 +79,9 @@ def __init__( abi: ABI, w3: "Web3", address: Optional[ChecksumAddress] = None, + decode_tuples: Optional[bool] = False, ) -> None: - super().__init__(abi, w3, ContractFunction, address) + super().__init__(abi, w3, ContractFunction, address, decode_tuples) class ContractEvents(BaseContractEvents): @@ -231,8 +232,12 @@ def __init__(self, address: Optional[ChecksumAddress] = None) -> None: "The address argument is required to instantiate a contract." ) - self.functions = ContractFunctions(self.abi, _w3, self.address) - self.caller = ContractCaller(self.abi, _w3, self.address) + self.functions = ContractFunctions( + self.abi, _w3, self.address, decode_tuples=self.decode_tuples + ) + self.caller = ContractCaller( + self.abi, _w3, self.address, decode_tuples=self.decode_tuples + ) self.events = ContractEvents(self.abi, _w3, self.address) self.fallback = Contract.get_fallback_function( self.abi, @@ -269,8 +274,15 @@ def factory( normalizers=normalizers, ), ) - contract.functions = ContractFunctions(contract.abi, contract.w3) - contract.caller = ContractCaller(contract.abi, contract.w3, contract.address) + contract.functions = ContractFunctions( + contract.abi, contract.w3, decode_tuples=contract.decode_tuples + ) + contract.caller = ContractCaller( + contract.abi, + contract.w3, + contract.address, + decode_tuples=contract.decode_tuples, + ) contract.events = ContractEvents(contract.abi, contract.w3) contract.fallback = Contract.get_fallback_function( contract.abi, @@ -404,6 +416,7 @@ def call( self.abi, state_override, ccip_read_enabled, + self.decode_tuples, *self.args, **self.kwargs, ) @@ -462,6 +475,7 @@ def __init__( transaction: Optional[TxParams] = None, block_identifier: BlockIdentifier = "latest", ccip_read_enabled: Optional[bool] = None, + decode_tuples: Optional[bool] = False, ) -> None: super().__init__( abi=abi, @@ -471,17 +485,18 @@ def __init__( block_identifier=block_identifier, ccip_read_enabled=ccip_read_enabled, contract_function_class=ContractFunction, + decode_tuples=decode_tuples, ) def __call__( self, transaction: Optional[TxParams] = None, block_identifier: BlockIdentifier = "latest", - state_override: Optional[CallOverride] = None, ccip_read_enabled: Optional[bool] = None, ) -> "ContractCaller": if transaction is None: transaction = {} + return type(self)( self.abi, self.w3, @@ -489,4 +504,5 @@ def __call__( transaction=transaction, block_identifier=block_identifier, ccip_read_enabled=ccip_read_enabled, + decode_tuples=self.decode_tuples, ) diff --git a/web3/contract/utils.py b/web3/contract/utils.py index afe4bc7307..cbd5e825eb 100644 --- a/web3/contract/utils.py +++ b/web3/contract/utils.py @@ -24,6 +24,8 @@ filter_by_type, get_abi_output_types, map_abi_data, + named_tree, + recursive_dict_to_namedtuple, ) from web3._utils.async_transactions import ( fill_transaction_defaults as async_fill_transaction_defaults, @@ -70,6 +72,7 @@ def call_contract_function( fn_abi: Optional[ABIFunction] = None, state_override: Optional[CallOverride] = None, ccip_read_enabled: Optional[bool] = None, + decode_tuples: Optional[bool] = False, *args: Any, **kwargs: Any, ) -> Any: @@ -129,6 +132,10 @@ def call_contract_function( ) normalized_data = map_abi_data(_normalizers, output_types, output_data) + if decode_tuples: + decoded = named_tree(fn_abi["outputs"], normalized_data) + normalized_data = recursive_dict_to_namedtuple(decoded) + if len(normalized_data) == 1: return normalized_data[0] else: @@ -273,6 +280,7 @@ async def async_call_contract_function( fn_abi: Optional[ABIFunction] = None, state_override: Optional[CallOverride] = None, ccip_read_enabled: Optional[bool] = None, + decode_tuples: Optional[bool] = False, *args: Any, **kwargs: Any, ) -> Any: @@ -332,6 +340,10 @@ async def async_call_contract_function( ) normalized_data = map_abi_data(_normalizers, output_types, output_data) + if decode_tuples: + decoded = named_tree(fn_abi["outputs"], normalized_data) + normalized_data = recursive_dict_to_namedtuple(decoded) + if len(normalized_data) == 1: return normalized_data[0] else: