Skip to content

Commit 961cdc8

Browse files
authored
feat(execute): Add identifiers to sent txs (#2056)
* feat(types): Add `metadata` field to transactions * feat(rpc): Embed tx metadata into the request id for send_transaction * feat(pytest/execute): Add transaction metadata to all transactions * docs: Add tx id documentation * fix: tox
1 parent 6cf066e commit 961cdc8

File tree

11 files changed

+253
-31
lines changed

11 files changed

+253
-31
lines changed

docs/running_tests/execute/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ See:
77
- [Execute Hive](./hive.md) for help with the `execute` simulator in order to run tests on a single-client local network.
88
- [Execute Remote](./remote.md) for help with executing tests on a remote network such as a devnet, or even mainnet.
99
- [Execute Eth Config](./eth_config.md) for help verifying client configurations on a remote network such as a devnet, or even mainnet.
10+
- [Transaction Metadata](./transaction_metadata.md) for detailed information about transaction metadata tracking in execute mode.
1011

1112
The rest of this page describes how `execute` works and explains its architecture.
1213

docs/running_tests/execute/remote.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ It is recommended to only run a subset of the tests when executing on a live net
3232
uv run execute remote --fork=Prague --rpc-endpoint=https://rpc.endpoint.io --rpc-seed-key 0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f --rpc-chain-id 12345 ./tests/prague/eip7702_set_code_tx/test_set_code_txs.py::test_set_code_to_sstore
3333
```
3434

35+
## Transaction Metadata on Remote Networks
36+
37+
When executing tests on remote networks, all transactions include metadata that helps with debugging and monitoring. This metadata is embedded in the RPC request ID and includes:
38+
39+
- **Test identification**: Each transaction is tagged with the specific test being executed
40+
- **Execution phase**: Transactions are categorized as setup, testing, or cleanup
41+
- **Action tracking**: Specific actions like contract deployment, funding, or refunding are tracked
42+
- **Target identification**: The account or contract being targeted is labeled
43+
44+
This metadata is particularly useful when debugging test failures on live networks, as it allows you to correlate blockchain transactions with specific test operations and phases.
45+
46+
See [Transaction Metadata](./transaction_metadata.md) for details.
47+
3548
## `execute` Command Test Execution
3649

3750
The `execute remote` and `execute hive` commands first creates a random sender account from which all required test accounts will be deployed and funded, and this account is funded by sweeping (by default) this "seed" account.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Transaction Metadata in Execute Mode
2+
3+
The `execute` plugin automatically adds metadata to all transactions it sends to the network. This feature was introduced to improve debugging, monitoring, and transaction tracking capabilities.
4+
5+
## Overview
6+
7+
Transaction metadata provides context about each transaction sent during test execution, making it easier to:
8+
9+
- Debug test failures by correlating blockchain transactions with test operations
10+
- Monitor test execution patterns and performance
11+
- Track transaction flow across different phases of test execution
12+
- Identify which transactions belong to which tests and phases
13+
14+
## Metadata Structure
15+
16+
Each transaction includes a `TransactionTestMetadata` object with the following fields:
17+
18+
| Field | Type | Description |
19+
|-------|------|-------------|
20+
| `testId` | `str` | The unique identifier of the test being executed (pytest node ID) |
21+
| `phase` | `str` | The execution phase: `setup`, `testing`, or `cleanup` |
22+
| `action` | `str` | The specific action being performed (e.g., `deploy_contract`, `fund_eoa`) |
23+
| `target` | `str` | The label of the account or contract being targeted |
24+
| `txIndex` | `int` | The index of the transaction within its phase |
25+
26+
## Transaction Phases
27+
28+
### Setup Phase (`setup`)
29+
30+
Transactions that prepare the test environment:
31+
32+
- **`deploy_contract`**: Contract deployment transactions
33+
- **`fund_eoa`**: Funding EOAs with initial balances
34+
- **`eoa_storage_set`**: Setting storage values for EOAs
35+
- **`fund_address`**: Funding specific addresses
36+
37+
### Testing Phase (`testing`)
38+
39+
The actual test transactions defined by the test:
40+
41+
- User-defined test transactions
42+
- Blob testing transactions
43+
44+
### Cleanup Phase (`cleanup`)
45+
46+
Transactions that clean up after the test:
47+
48+
- **`refund_from_eoa`**: Refunding EOAs back to the sender account
49+
50+
## Example Metadata
51+
52+
```json
53+
{
54+
"testId": "tests/example_test.py::test_example",
55+
"phase": "setup",
56+
"action": "deploy_contract",
57+
"target": "contract_label",
58+
"txIndex": 0
59+
}
60+
```
61+
62+
## Debugging Test Failures
63+
64+
When a test fails on a remote network, you can use the transaction metadata to:
65+
66+
1. Identify which transactions belong to the failing test
67+
2. Determine which phase of execution failed
68+
3. Correlate blockchain transactions with specific test operations
69+
70+
The ID will normally be printed in the client logs when execute is running tests against the client, but the logging level might need to be increased for some of the clients (`--sim.loglevel` when running with hive).
71+
72+
## Technical Notes
73+
74+
- Metadata is automatically handled by the execute plugin
75+
- No additional configuration is required
76+
- Metadata is embedded in RPC requests without affecting transaction execution
77+
- The feature is backward compatible and doesn't change test behavior

src/ethereum_test_execution/base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Annotated, Any, ClassVar, Dict, Type
55

66
from pydantic import PlainSerializer, PlainValidator
7+
from pytest import FixtureRequest
78

89
from ethereum_test_base_types import CamelModel
910
from ethereum_test_forks import Fork
@@ -32,7 +33,9 @@ def __pydantic_init_subclass__(cls, **kwargs):
3233
BaseExecute.formats[cls.format_name] = cls
3334

3435
@abstractmethod
35-
def execute(self, fork: Fork, eth_rpc: EthRPC, engine_rpc: EngineRPC | None):
36+
def execute(
37+
self, fork: Fork, eth_rpc: EthRPC, engine_rpc: EngineRPC | None, request: FixtureRequest
38+
):
3639
"""Execute the format."""
3740
pass
3841

src/ethereum_test_execution/blob_transaction.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from hashlib import sha256
44
from typing import ClassVar, Dict, List
55

6-
from ethereum_test_base_types import Hash
6+
from pytest import FixtureRequest
7+
8+
from ethereum_test_base_types import Address, Hash
79
from ethereum_test_base_types.base_types import Bytes
810
from ethereum_test_forks import Fork
911
from ethereum_test_rpc import BlobAndProofV1, BlobAndProofV2, EngineRPC, EthRPC
10-
from ethereum_test_types import NetworkWrappedTransaction, Transaction
12+
from ethereum_test_types import NetworkWrappedTransaction, Transaction, TransactionTestMetadata
1113

1214
from .base import BaseExecute
1315

@@ -53,22 +55,33 @@ class BlobTransaction(BaseExecute):
5355

5456
txs: List[NetworkWrappedTransaction | Transaction]
5557

56-
def execute(self, fork: Fork, eth_rpc: EthRPC, engine_rpc: EngineRPC | None):
58+
def execute(
59+
self, fork: Fork, eth_rpc: EthRPC, engine_rpc: EngineRPC | None, request: FixtureRequest
60+
):
5761
"""Execute the format."""
5862
assert engine_rpc is not None, "Engine RPC is required for this format."
5963
versioned_hashes: Dict[Hash, BlobAndProofV1 | BlobAndProofV2] = {}
6064
sent_txs: List[Transaction] = []
61-
for tx in self.txs:
65+
for tx_index, tx in enumerate(self.txs):
6266
if isinstance(tx, NetworkWrappedTransaction):
6367
tx.tx = tx.tx.with_signature_and_sender()
6468
sent_txs.append(tx.tx)
6569
expected_hash = tx.tx.hash
6670
versioned_hashes.update(versioned_hashes_with_blobs_and_proofs(tx))
71+
to_address = tx.tx.to
6772
else:
6873
tx = tx.with_signature_and_sender()
6974
sent_txs.append(tx)
7075
expected_hash = tx.hash
71-
received_hash = eth_rpc.send_raw_transaction(tx.rlp())
76+
to_address = tx.to
77+
label = to_address.label if isinstance(to_address, Address) else None
78+
metadata = TransactionTestMetadata(
79+
test_id=request.node.nodeid,
80+
phase="testing",
81+
target=label,
82+
tx_index=tx_index,
83+
)
84+
received_hash = eth_rpc.send_raw_transaction(tx.rlp(), request_id=metadata.to_json())
7285
assert expected_hash == received_hash, (
7386
f"Expected hash {expected_hash} does not match received hash {received_hash}."
7487
)

src/ethereum_test_execution/transaction_post.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
from typing import ClassVar, List
44

55
import pytest
6+
from pytest import FixtureRequest
67

7-
from ethereum_test_base_types import Alloc, Hash
8+
from ethereum_test_base_types import Address, Alloc, Hash
89
from ethereum_test_forks import Fork
910
from ethereum_test_rpc import EngineRPC, EthRPC, SendTransactionExceptionError
10-
from ethereum_test_types import Transaction
11+
from ethereum_test_types import Transaction, TransactionTestMetadata
1112

1213
from .base import BaseExecute
1314

@@ -23,21 +24,36 @@ class TransactionPost(BaseExecute):
2324
"Simple transaction sending, then post-check after all transactions are included"
2425
)
2526

26-
def execute(self, fork: Fork, eth_rpc: EthRPC, engine_rpc: EngineRPC | None):
27+
def execute(
28+
self, fork: Fork, eth_rpc: EthRPC, engine_rpc: EngineRPC | None, request: FixtureRequest
29+
):
2730
"""Execute the format."""
2831
assert not any(tx.ty == 3 for block in self.blocks for tx in block), (
2932
"Transaction type 3 is not supported in execute mode."
3033
)
3134
for block in self.blocks:
32-
if any(tx.error is not None for tx in block):
33-
for transaction in block:
35+
signed_txs = []
36+
for tx_index, tx in enumerate(block):
37+
# Add metadata
38+
tx = tx.with_signature_and_sender()
39+
to_address = tx.to
40+
label = to_address.label if isinstance(to_address, Address) else None
41+
tx.metadata = TransactionTestMetadata(
42+
test_id=request.node.nodeid,
43+
phase="testing",
44+
target=label,
45+
tx_index=tx_index,
46+
)
47+
signed_txs.append(tx)
48+
if any(tx.error is not None for tx in signed_txs):
49+
for transaction in signed_txs:
3450
if transaction.error is None:
35-
eth_rpc.send_wait_transaction(transaction.with_signature_and_sender())
51+
eth_rpc.send_wait_transaction(transaction)
3652
else:
3753
with pytest.raises(SendTransactionExceptionError):
38-
eth_rpc.send_transaction(transaction.with_signature_and_sender())
54+
eth_rpc.send_transaction(transaction)
3955
else:
40-
eth_rpc.send_wait_transactions([tx.with_signature_and_sender() for tx in block])
56+
eth_rpc.send_wait_transactions(signed_txs)
4157

4258
for address, account in self.post.root.items():
4359
balance = eth_rpc.get_balance(address)

src/ethereum_test_rpc/rpc.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,26 @@ def __init_subclass__(cls) -> None:
7575
namespace = namespace.removesuffix("RPC")
7676
cls.namespace = namespace.lower()
7777

78-
def post_request(self, method: str, *params: Any, extra_headers: Dict | None = None) -> Any:
78+
def post_request(
79+
self,
80+
method: str,
81+
*params: Any,
82+
extra_headers: Dict | None = None,
83+
request_id: int | str | None = None,
84+
) -> Any:
7985
"""Send JSON-RPC POST request to the client RPC server at port defined in the url."""
8086
if extra_headers is None:
8187
extra_headers = {}
8288
assert self.namespace, "RPC namespace not set"
8389

90+
next_request_id_counter = next(self.request_id_counter)
91+
if request_id is None:
92+
request_id = next_request_id_counter
8493
payload = {
8594
"jsonrpc": "2.0",
8695
"method": f"{self.namespace}_{method}",
8796
"params": params,
88-
"id": next(self.request_id_counter),
97+
"id": request_id,
8998
}
9099
base_header = {
91100
"Content-Type": "application/json",
@@ -197,10 +206,16 @@ def gas_price(self) -> int:
197206
"""`eth_gasPrice`: Returns the number of transactions sent from an address."""
198207
return int(self.post_request("gasPrice"), 16)
199208

200-
def send_raw_transaction(self, transaction_rlp: Bytes) -> Hash:
209+
def send_raw_transaction(
210+
self, transaction_rlp: Bytes, request_id: int | str | None = None
211+
) -> Hash:
201212
"""`eth_sendRawTransaction`: Send a transaction to the client."""
202213
try:
203-
result_hash = Hash(self.post_request("sendRawTransaction", f"{transaction_rlp.hex()}"))
214+
result_hash = Hash(
215+
self.post_request(
216+
"sendRawTransaction", f"{transaction_rlp.hex()}", request_id=request_id
217+
),
218+
)
204219
assert result_hash is not None
205220
return result_hash
206221
except Exception as e:
@@ -210,7 +225,11 @@ def send_transaction(self, transaction: Transaction) -> Hash:
210225
"""`eth_sendRawTransaction`: Send a transaction to the client."""
211226
try:
212227
result_hash = Hash(
213-
self.post_request("sendRawTransaction", f"{transaction.rlp().hex()}")
228+
self.post_request(
229+
"sendRawTransaction",
230+
f"{transaction.rlp().hex()}",
231+
request_id=transaction.metadata_string(),
232+
)
214233
)
215234
assert result_hash == transaction.hash
216235
assert result_hash is not None
@@ -312,7 +331,13 @@ class EngineRPC(BaseRPC):
312331
simulators.
313332
"""
314333

315-
def post_request(self, method: str, *params: Any, extra_headers: Dict | None = None) -> Any:
334+
def post_request(
335+
self,
336+
method: str,
337+
*params: Any,
338+
extra_headers: Dict | None = None,
339+
request_id: int | str | None = None,
340+
) -> Any:
316341
"""Send JSON-RPC POST request to the client RPC server at port defined in the url."""
317342
if extra_headers is None:
318343
extra_headers = {}
@@ -324,7 +349,9 @@ def post_request(self, method: str, *params: Any, extra_headers: Dict | None = N
324349
extra_headers = {
325350
"Authorization": f"Bearer {jwt_token}",
326351
} | extra_headers
327-
return super().post_request(method, *params, extra_headers=extra_headers)
352+
return super().post_request(
353+
method, *params, extra_headers=extra_headers, request_id=request_id
354+
)
328355

329356
def new_payload(self, *params: Any, version: int) -> PayloadStatus:
330357
"""`engine_newPayloadVX`: Attempts to execute the given payload on an execution client."""

src/ethereum_test_types/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
NetworkWrappedTransaction,
2828
Transaction,
2929
TransactionDefaults,
30+
TransactionTestMetadata,
3031
TransactionType,
3132
)
3233
from .utils import Removable, keccak256
@@ -47,6 +48,7 @@
4748
"Transaction",
4849
"TransactionDefaults",
4950
"TransactionReceipt",
51+
"TransactionTestMetadata",
5052
"TransactionType",
5153
"Withdrawal",
5254
"WithdrawalRequest",

src/ethereum_test_types/transaction_types.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ class TransactionGeneric(BaseModel, Generic[NumberBoundTypeVar]):
185185
s: NumberBoundTypeVar = Field(0) # type: ignore
186186
sender: EOA | None = None
187187

188+
def metadata_string(self) -> str | None:
189+
"""Return the metadata field as a formatted json string or None."""
190+
return None
191+
188192

189193
class TransactionValidateToAsEmptyString(CamelModel):
190194
"""Handler to validate the `to` field from an empty string."""
@@ -233,6 +237,23 @@ def serialize_to_as_none(self, serializer):
233237
return default
234238

235239

240+
class TransactionTestMetadata(CamelModel):
241+
"""Represents the metadata for a transaction."""
242+
243+
test_id: str | None = None
244+
phase: str | None = None
245+
action: str | None = None # e.g. deploy / fund / execute
246+
target: str | None = None # account/contract label
247+
tx_index: int | None = None # index within this phase
248+
249+
def to_json(self) -> str:
250+
"""
251+
Convert the transaction metadata into json string for it to be embedded in the
252+
request id.
253+
"""
254+
return self.model_dump_json(exclude_none=True, by_alias=True)
255+
256+
236257
class Transaction(
237258
TransactionGeneric[HexNumber], TransactionTransitionToolConverter, SignableRLPSerializable
238259
):
@@ -255,6 +276,8 @@ class Transaction(
255276

256277
zero: ClassVar[Literal[0]] = 0
257278

279+
metadata: TransactionTestMetadata | None = Field(None, exclude=True)
280+
258281
model_config = ConfigDict(validate_assignment=True)
259282

260283
class InvalidFeePaymentError(Exception):
@@ -612,6 +635,12 @@ def get_rlp_signing_prefix(self) -> bytes:
612635
return bytes([self.ty])
613636
return b""
614637

638+
def metadata_string(self) -> str | None:
639+
"""Return the metadata field as a formatted json string or None."""
640+
if self.metadata is None:
641+
return None
642+
return self.metadata.to_json()
643+
615644
@cached_property
616645
def hash(self) -> Hash:
617646
"""Returns hash of the transaction."""

0 commit comments

Comments
 (0)