diff --git a/.wordlist_python_pytest.txt b/.wordlist_python_pytest.txt index 2a387ff346..50f4aa9620 100644 --- a/.wordlist_python_pytest.txt +++ b/.wordlist_python_pytest.txt @@ -6,6 +6,7 @@ callspec collectonly dedent dest +exc fixturenames fspath funcargs diff --git a/src/ethereum_test_tools/common/types.py b/src/ethereum_test_tools/common/types.py index 24c14fc722..ee14a7475a 100644 --- a/src/ethereum_test_tools/common/types.py +++ b/src/ethereum_test_tools/common/types.py @@ -349,12 +349,14 @@ class KeyValueMismatch(Exception): was different. """ + address: str key: int want: int got: int - def __init__(self, key: int, want: int, got: int, *args): + def __init__(self, address: str, key: int, want: int, got: int, *args): super().__init__(args) + self.address = address self.key = key self.want = want self.got = got @@ -362,13 +364,10 @@ def __init__(self, key: int, want: int, got: int, *args): def __str__(self): """Print exception string""" return ( - "incorrect value for key {0}: want {1} (dec:{2})," + " got {3} (dec:{4})" - ).format( - Storage.key_value_to_string(self.key), - Storage.key_value_to_string(self.want), - self.want, - Storage.key_value_to_string(self.got), - self.got, + f"incorrect value in address {self.address} for " + + f"key {Storage.key_value_to_string(self.key)}:" + + f" want {Storage.key_value_to_string(self.want)} (dec:{self.want})," + + f" got {Storage.key_value_to_string(self.got)} (dec:{self.got})" ) @staticmethod @@ -477,7 +476,7 @@ def contains(self, other: "Storage") -> bool: return False return True - def must_contain(self, other: "Storage"): + def must_contain(self, address: str, other: "Storage"): """ Succeeds only if self contains all keys with equal value as contained by second storage. @@ -491,25 +490,25 @@ def must_contain(self, other: "Storage"): if other[key] != 0: raise Storage.MissingKey(key) elif self.data[key] != other.data[key]: - raise Storage.KeyValueMismatch(key, self.data[key], other.data[key]) + raise Storage.KeyValueMismatch(address, key, self.data[key], other.data[key]) - def must_be_equal(self, other: "Storage"): + def must_be_equal(self, address: str, other: "Storage"): """ Succeeds only if "self" is equal to "other" storage. """ # Test keys contained in both storage objects for key in self.data.keys() & other.data.keys(): if self.data[key] != other.data[key]: - raise Storage.KeyValueMismatch(key, self.data[key], other.data[key]) + raise Storage.KeyValueMismatch(address, key, self.data[key], other.data[key]) # Test keys contained in either one of the storage objects for key in self.data.keys() ^ other.data.keys(): if key in self.data: if self.data[key] != 0: - raise Storage.KeyValueMismatch(key, self.data[key], 0) + raise Storage.KeyValueMismatch(address, key, self.data[key], 0) elif other.data[key] != 0: - raise Storage.KeyValueMismatch(key, 0, other.data[key]) + raise Storage.KeyValueMismatch(address, key, 0, other.data[key]) @dataclass(kw_only=True) @@ -681,7 +680,7 @@ def check_alloc(self: "Account", address: str, alloc: dict): self.storage if isinstance(self.storage, Storage) else Storage(self.storage) ) actual_storage = Storage(alloc["storage"]) if "storage" in alloc else Storage({}) - expected_storage.must_be_equal(actual_storage) + expected_storage.must_be_equal(address=address, other=actual_storage) @classmethod def with_code(cls: Type, code: BytesConvertible) -> "Account": @@ -1249,10 +1248,10 @@ def __post_init__(self) -> None: """ Ensures the transaction has no conflicting properties. """ - if ( - self.gas_price is not None - and self.max_fee_per_gas is not None - and self.max_priority_fee_per_gas is not None + if self.gas_price is not None and ( + self.max_fee_per_gas is not None + or self.max_priority_fee_per_gas is not None + or self.max_fee_per_data_gas is not None ): raise Transaction.InvalidFeePayment() @@ -1260,6 +1259,7 @@ def __post_init__(self) -> None: self.gas_price is None and self.max_fee_per_gas is None and self.max_priority_fee_per_gas is None + and self.max_fee_per_data_gas is None ): self.gas_price = 10 @@ -1280,6 +1280,13 @@ def __post_init__(self) -> None: else: self.ty = 0 + # Set default values for fields that are required for certain tx types + if self.ty >= 1 and self.access_list is None: + self.access_list = [] + + if self.ty >= 2 and self.max_priority_fee_per_gas is None: + self.max_priority_fee_per_gas = 0 + def with_error(self, error: str) -> "Transaction": """ Create a copy of the transaction with an added error. @@ -1329,6 +1336,8 @@ def payload_body(self) -> List[Any]: raise ValueError("max_fee_per_data_gas must be set for type 3 tx") if self.blob_versioned_hashes is None: raise ValueError("blob_versioned_hashes must be set for type 3 tx") + if self.access_list is None: + raise ValueError("access_list must be set for type 3 tx") if self.wrapped_blob_transaction: if self.blobs is None: @@ -1352,9 +1361,7 @@ def payload_body(self) -> List[Any]: to, Uint(self.value), Bytes(self.data), - [a.to_list() for a in self.access_list] - if self.access_list is not None - else [], + [a.to_list() for a in self.access_list], Uint(self.max_fee_per_data_gas), [Hash(h) for h in self.blob_versioned_hashes], Uint(self.v), @@ -1375,9 +1382,7 @@ def payload_body(self) -> List[Any]: to, Uint(self.value), Bytes(self.data), - [a.to_list() for a in self.access_list] - if self.access_list is not None - else [], + [a.to_list() for a in self.access_list], Uint(self.max_fee_per_data_gas), [Hash(h) for h in self.blob_versioned_hashes], Uint(self.v), @@ -1387,9 +1392,11 @@ def payload_body(self) -> List[Any]: elif self.ty == 2: # EIP-1559: https://eips.ethereum.org/EIPS/eip-1559 if self.max_priority_fee_per_gas is None: - raise ValueError("max_priority_fee_per_gas must be set for type 3 tx") + raise ValueError("max_priority_fee_per_gas must be set for type 2 tx") if self.max_fee_per_gas is None: - raise ValueError("max_fee_per_gas must be set for type 3 tx") + raise ValueError("max_fee_per_gas must be set for type 2 tx") + if self.access_list is None: + raise ValueError("access_list must be set for type 2 tx") return [ Uint(self.chain_id), Uint(self.nonce), @@ -1399,7 +1406,7 @@ def payload_body(self) -> List[Any]: to, Uint(self.value), Bytes(self.data), - [a.to_list() for a in self.access_list] if self.access_list is not None else [], + [a.to_list() for a in self.access_list], Uint(self.v), Uint(self.r), Uint(self.s), @@ -1408,6 +1415,8 @@ def payload_body(self) -> List[Any]: # EIP-2930: https://eips.ethereum.org/EIPS/eip-2930 if self.gas_price is None: raise ValueError("gas_price must be set for type 1 tx") + if self.access_list is None: + raise ValueError("access_list must be set for type 1 tx") return [ Uint(self.chain_id), @@ -1417,7 +1426,7 @@ def payload_body(self) -> List[Any]: to, Uint(self.value), Bytes(self.data), - [a.to_list() for a in self.access_list] if self.access_list is not None else [], + [a.to_list() for a in self.access_list], Uint(self.v), Uint(self.r), Uint(self.s), @@ -1487,9 +1496,9 @@ def signing_envelope(self) -> List[Any]: elif self.ty == 2: # EIP-1559: https://eips.ethereum.org/EIPS/eip-1559 if self.max_priority_fee_per_gas is None: - raise ValueError("max_priority_fee_per_gas must be set for type 3 tx") + raise ValueError("max_priority_fee_per_gas must be set for type 2 tx") if self.max_fee_per_gas is None: - raise ValueError("max_fee_per_gas must be set for type 3 tx") + raise ValueError("max_fee_per_gas must be set for type 2 tx") return [ Uint(self.chain_id), Uint(self.nonce), diff --git a/src/ethereum_test_tools/tests/test_fixtures/blockchain_london_invalid_filled.json b/src/ethereum_test_tools/tests/test_fixtures/blockchain_london_invalid_filled.json index 67d205dca9..1606c86351 100644 --- a/src/ethereum_test_tools/tests/test_fixtures/blockchain_london_invalid_filled.json +++ b/src/ethereum_test_tools/tests/test_fixtures/blockchain_london_invalid_filled.json @@ -34,6 +34,7 @@ "gasLimit": "0x0f4240", "maxFeePerGas": "0x03e8", "maxPriorityFeePerGas": "0x01", + "accessList": [], "v": "0x00", "r": "0x3351b6993208fc7b03fd770c8c06440cfb0d75b29aafee0a4c64c8ba20a80e58", "s": "0x67817fdb3058e75c5d26e51a33d1e338346bc7d406e115447a4bb5f7ab01625b", @@ -75,6 +76,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x0a", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x00", "r": "0x6ea285a870a051df2b8c80c462b7d3517f984815e09c4748efc8548a40434050", "s": "0x52f635268c1b9e1538ac76b37cb69c7b897595744d6de2dda9507b6624d352d0", @@ -90,6 +92,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x64", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x00", "r": "0x218549e818b36b3823c3f11a65ab5c1e16f6886469c385503cc2f1af1f53825d", "s": "0x58b082850f55fd61290a99add11b7af6356ac8d55fbe4d513f06bf648824a64d", @@ -105,6 +108,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x64", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x01", "r": "0x339e9ed3f6342f2644e4cd33a775b7e62a8208a137dcf2e354c7473caa77782a", "s": "0x74004c85b651c8ca9828aac28414997f3eff46edbba2bb606a545d95fd4c9b3a", @@ -151,6 +155,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x03e8", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x01", "r": "0x720e2870881f8b0e285b7ec02c169f1165847bcb5f36ea5f33f3db6079854f63", "s": "0x4448266b715d7d99acd1e31dcab50d7119faa620d44c69b3f64f97d636634169", @@ -166,6 +171,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x64", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x00", "r": "0x9c8531a41f9281633470c5e12b6c72c8930409a6433f26bf7b394a703d18512e", "s": "0x7a0c6151fde75f10a7e4efdd17a21f1f25206559bd4b8cf7880e5bc30e1cfe33", @@ -181,6 +187,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x0186a0", "maxFeePerGas": "0x0186a0", + "accessList": [], "v": "0x01", "r": "0xc8b85e158b532a0e3b3b5848fad0f4d5c6807805a4ce65e8591de13a62f3ac6a", "s": "0x3e923eb1be030c3ca69623f31ad3a357368b1ccb7ee48ac8deec5cb5dc49cb0c", @@ -227,6 +234,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x03e8", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x01", "r": "0x113c54f83e1b1e5c689ba86d288ec0ce2877f350b71821c4c7a3f7073b46602c", "s": "0x548848e711b86ceeb657fd0a0bf44b792f6665ed18ec8a04f498471e811f8f97", @@ -242,6 +250,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x64", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x00", "r": "0x8d7ec1116399aab6e1297b09302b291d73c5898a0338fb62a46c74b037d15a15", "s": "0x3cacc1a12eb47c261394443d490b8436f53a99d2109dac9ca5018cf531e6b29d", @@ -257,6 +266,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x0186a0", "maxFeePerGas": "0x0186a0", + "accessList": [], "v": "0x01", "r": "0x54bd3a30ee3c2182d92f30223adb53feb0f51d76970a2628d9479536ff3edfe9", "s": "0x6f681aa0ad9362eeeafb981394526ca6425f3a24e1c7f44c413b68dd2e56e5d0", diff --git a/src/ethereum_test_tools/tests/test_fixtures/blockchain_london_valid_filled.json b/src/ethereum_test_tools/tests/test_fixtures/blockchain_london_valid_filled.json index 21bcff988f..1010745bf3 100644 --- a/src/ethereum_test_tools/tests/test_fixtures/blockchain_london_valid_filled.json +++ b/src/ethereum_test_tools/tests/test_fixtures/blockchain_london_valid_filled.json @@ -34,6 +34,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x01", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x00", "r": "0x3351b6993208fc7b03fd770c8c06440cfb0d75b29aafee0a4c64c8ba20a80e58", "s": "0x67817fdb3058e75c5d26e51a33d1e338346bc7d406e115447a4bb5f7ab01625b", @@ -75,6 +76,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x0a", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x00", "r": "0x6ea285a870a051df2b8c80c462b7d3517f984815e09c4748efc8548a40434050", "s": "0x52f635268c1b9e1538ac76b37cb69c7b897595744d6de2dda9507b6624d352d0", @@ -90,6 +92,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x64", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x00", "r": "0x218549e818b36b3823c3f11a65ab5c1e16f6886469c385503cc2f1af1f53825d", "s": "0x58b082850f55fd61290a99add11b7af6356ac8d55fbe4d513f06bf648824a64d", @@ -105,6 +108,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x64", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x01", "r": "0x339e9ed3f6342f2644e4cd33a775b7e62a8208a137dcf2e354c7473caa77782a", "s": "0x74004c85b651c8ca9828aac28414997f3eff46edbba2bb606a545d95fd4c9b3a", @@ -146,6 +150,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x03e8", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x01", "r": "0x720e2870881f8b0e285b7ec02c169f1165847bcb5f36ea5f33f3db6079854f63", "s": "0x4448266b715d7d99acd1e31dcab50d7119faa620d44c69b3f64f97d636634169", @@ -161,6 +166,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x64", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x00", "r": "0x9c8531a41f9281633470c5e12b6c72c8930409a6433f26bf7b394a703d18512e", "s": "0x7a0c6151fde75f10a7e4efdd17a21f1f25206559bd4b8cf7880e5bc30e1cfe33", @@ -176,6 +182,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x0186a0", "maxFeePerGas": "0x0186a0", + "accessList": [], "v": "0x01", "r": "0xc8b85e158b532a0e3b3b5848fad0f4d5c6807805a4ce65e8591de13a62f3ac6a", "s": "0x3e923eb1be030c3ca69623f31ad3a357368b1ccb7ee48ac8deec5cb5dc49cb0c", @@ -217,6 +224,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x03e8", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x01", "r": "0x113c54f83e1b1e5c689ba86d288ec0ce2877f350b71821c4c7a3f7073b46602c", "s": "0x548848e711b86ceeb657fd0a0bf44b792f6665ed18ec8a04f498471e811f8f97", @@ -232,6 +240,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x64", "maxFeePerGas": "0x03e8", + "accessList": [], "v": "0x00", "r": "0x8d7ec1116399aab6e1297b09302b291d73c5898a0338fb62a46c74b037d15a15", "s": "0x3cacc1a12eb47c261394443d490b8436f53a99d2109dac9ca5018cf531e6b29d", @@ -247,6 +256,7 @@ "gasLimit": "0x0f4240", "maxPriorityFeePerGas": "0x0186a0", "maxFeePerGas": "0x0186a0", + "accessList": [], "v": "0x01", "r": "0x54bd3a30ee3c2182d92f30223adb53feb0f51d76970a2628d9479536ff3edfe9", "s": "0x6f681aa0ad9362eeeafb981394526ca6425f3a24e1c7f44c413b68dd2e56e5d0", diff --git a/src/ethereum_test_tools/tests/test_types.py b/src/ethereum_test_tools/tests/test_types.py index 11e4a5075a..a71b32ad2a 100644 --- a/src/ethereum_test_tools/tests/test_types.py +++ b/src/ethereum_test_tools/tests/test_types.py @@ -16,7 +16,7 @@ Withdrawal, to_json, ) -from ..common.constants import TestAddress2 +from ..common.constants import TestPrivateKey from ..common.types import ( Address, Bloom, @@ -518,7 +518,7 @@ def test_account_check_alloc(account: Account, alloc: Dict[Any, Any], should_pas "s": "0x2020cb35f5d7731ab540d62614503a7f2344301a86342f67daf011c1341551ff", "sender": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b", }, - id="fixture_transaction_1", + id="fixture_transaction_type_0_default_values", ), pytest.param( FixtureTransaction.from_transaction( @@ -540,7 +540,77 @@ def test_account_check_alloc(account: Account, alloc: Dict[Any, Any], should_pas "s": "0x0cbe2d029f52dbf93ade486625bed0603945d2c7358b31de99fe8786c00f13da", "sender": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b", }, - id="fixture_transaction_2", + id="fixture_transaction_type_0_contract_creation", + ), + pytest.param( + FixtureTransaction.from_transaction(Transaction(ty=1).with_signature_and_sender()), + { + "type": "0x01", + "chainId": "0x01", + "nonce": "0x00", + "to": "0x00000000000000000000000000000000000000aa", + "value": "0x00", + "data": "0x", + "gasLimit": "0x5208", + "gasPrice": "0x0a", + "accessList": [], + "v": "0x01", + "r": "0x58b4ddaa529492d32b6bc8327eb8ee0bc8b535c3bfc0f4f1db3d7c16b51d1851", + "s": "0x5ef19167661b14d06dfc785bf62693e6f9e5a44e7c11e0320efed27b27294970", + "sender": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b", + }, + id="fixture_transaction_type_1_default_values", + ), + pytest.param( + FixtureTransaction.from_transaction( + Transaction(ty=2, max_fee_per_gas=7).with_signature_and_sender() + ), + { + "type": "0x02", + "chainId": "0x01", + "nonce": "0x00", + "to": "0x00000000000000000000000000000000000000aa", + "value": "0x00", + "data": "0x", + "gasLimit": "0x5208", + "maxPriorityFeePerGas": "0x00", + "maxFeePerGas": "0x07", + "accessList": [], + "v": "0x00", + "r": "0x33fc39081d01f8e7f0ce5426d4a00a7b07c2edea064d24a8cac8e4b1f0c08298", + "s": "0x4635e1c45238697db38e37070d4fce27fb5684f9dec4046466ea42a9834bad0a", + "sender": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b", + }, + id="fixture_transaction_type_2_default_values", + ), + pytest.param( + FixtureTransaction.from_transaction( + Transaction( + ty=3, + max_fee_per_gas=7, + max_fee_per_data_gas=1, + blob_versioned_hashes=[], + ).with_signature_and_sender() + ), + { + "type": "0x03", + "chainId": "0x01", + "nonce": "0x00", + "to": "0x00000000000000000000000000000000000000aa", + "value": "0x00", + "data": "0x", + "gasLimit": "0x5208", + "maxPriorityFeePerGas": "0x00", + "maxFeePerGas": "0x07", + "maxFeePerDataGas": "0x01", + "accessList": [], + "blobVersionedHashes": [], + "v": "0x01", + "r": "0x8978475a00bf155bf5687dfda89c2df55ef6c341cdfd689aeaa6c519569a530a", + "s": "0x66fc34935cdd191441a12a2e7b1f224cb40b928afb9bc89c8ddb2b78c19342cc", + "sender": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b", + }, + id="fixture_transaction_type_3_default_values", ), pytest.param( FixtureTransaction.from_transaction( @@ -949,3 +1019,109 @@ def test_account_check_alloc(account: Account, alloc: Dict[Any, Any], should_pas ) def test_json_conversions(obj: Any, expected_json: str | Dict[str, Any]): assert to_json(obj) == expected_json + + +@pytest.mark.parametrize( + ["invalid_tx_args", "expected_exception", "expected_exception_substring"], + [ + pytest.param( + {"gas_price": 1, "max_fee_per_gas": 2}, + Transaction.InvalidFeePayment, + "only one type of fee payment field can be used", + id="gas-price-and-max-fee-per-gas", + ), + pytest.param( + {"gas_price": 1, "max_priority_fee_per_gas": 2}, + Transaction.InvalidFeePayment, + "only one type of fee payment field can be used", + id="gas-price-and-max-priority-fee-per-gas", + ), + pytest.param( + {"gas_price": 1, "max_fee_per_data_gas": 2}, + Transaction.InvalidFeePayment, + "only one type of fee payment field can be used", + id="gas-price-and-max-fee-per-data-gas", + ), + pytest.param( + {"ty": 0, "v": 1, "secret_key": 2}, + Transaction.InvalidSignaturePrivateKey, + "can't define both 'signature' and 'private_key'", + id="type0-signature-and-secret-key", + ), + ], +) +def test_transaction_post_init_invalid_arg_combinations( # noqa: D103 + invalid_tx_args, expected_exception, expected_exception_substring +): + """ + Test that Transaction.__post_init__ raises the expected exceptions for + invalid constructor argument combinations. + """ + with pytest.raises(expected_exception) as exc_info: + Transaction(**invalid_tx_args) + assert expected_exception_substring in str(exc_info.value) + + +@pytest.mark.parametrize( + ["tx_args", "expected_attributes_and_values"], + [ + pytest.param( + {"max_fee_per_data_gas": 10}, + [ + ("ty", 3), + ], + id="max_fee_per_data_gas-adds-ty-3", + ), + pytest.param( + {}, + [ + ("gas_price", 10), + ], + id="no-fees-adds-gas_price", + ), + pytest.param( + {}, + [ + ("secret_key", TestPrivateKey), + ], + id="no-signature-adds-secret_key", + ), + pytest.param( + {"max_fee_per_gas": 10}, + [ + ("ty", 2), + ], + id="max_fee_per_gas-adds-ty-2", + ), + pytest.param( + {"access_list": [AccessList(address=0x1234, storage_keys=[0, 1])]}, + [ + ("ty", 1), + ], + id="access_list-adds-ty-1", + ), + pytest.param( + {"ty": 1}, + [ + ("access_list", []), + ], + id="ty-1-adds-empty-access_list", + ), + pytest.param( + {"ty": 2}, + [ + ("max_priority_fee_per_gas", 0), + ], + id="ty-2-adds-max_priority_fee_per_gas", + ), + ], +) +def test_transaction_post_init_defaults(tx_args, expected_attributes_and_values): + """ + Test that Transaction.__post_init__ sets the expected default values for + missing fields. + """ + tx = Transaction(**tx_args) + for attr, val in expected_attributes_and_values: + assert hasattr(tx, attr) + assert getattr(tx, attr) == val