From aeaf7fa02204a2ed375071c920036c76fe789e96 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Tue, 17 Feb 2026 18:00:57 +0100 Subject: [PATCH 1/8] feat: introduce EVM contract benchmarking with new tests and a GitHub Actions workflow. --- .github/workflows/benchmark.yml | 42 +++++++++++ execution/evm/test/test_helpers.go | 19 +++-- execution/evm/test_helpers.go | 2 +- test/e2e/evm_contract_bench_test.go | 104 ++++++++++++++++++++++++++++ test/e2e/evm_contract_e2e_test.go | 4 +- test/e2e/evm_test_common.go | 30 ++++---- test/e2e/sut_helper.go | 16 ++--- 7 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/benchmark.yml create mode 100644 test/e2e/evm_contract_bench_test.go diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000000..e34f615027 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,42 @@ +--- +name: Benchmarks +permissions: {} +"on": + push: + branches: + - main + workflow_dispatch: + +jobs: + evm-benchmark: + name: EVM Contract Benchmark + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + issues: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: ./go.mod + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + - name: Build binaries + run: make build-evm build-da + - name: Run EVM benchmarks + run: | + cd test/e2e && go test -tags evm -bench=. -benchmem -run='^$' \ + -timeout=10m --evm-binary=../../build/evm | tee output.txt + - name: Store benchmark result + uses: benchmark-action/github-action-benchmark@4bdcce38c94cec68da58d012ac24b7b1155efe8b # v1.20.7 + with: + name: EVM Contract Roundtrip + tool: 'go' + output-file-path: test/e2e/output.txt + auto-push: true + github-token: ${{ secrets.GITHUB_TOKEN }} + alert-threshold: '150%' + fail-on-alert: true + comment-on-alert: true diff --git a/execution/evm/test/test_helpers.go b/execution/evm/test/test_helpers.go index d2c0500528..1aa4ec3175 100644 --- a/execution/evm/test/test_helpers.go +++ b/execution/evm/test/test_helpers.go @@ -18,6 +18,8 @@ import ( "github.com/celestiaorg/tastora/framework/types" "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" ) // Test-scoped Docker client/network mapping to avoid conflicts between tests @@ -39,7 +41,7 @@ func randomString(n int) string { } // getTestScopedDockerSetup returns a Docker client and network ID that are scoped to the specific test. -func getTestScopedDockerSetup(t *testing.T) (types.TastoraDockerClient, string) { +func getTestScopedDockerSetup(t testing.TB) (types.TastoraDockerClient, string) { t.Helper() testKey := t.Name() @@ -59,13 +61,22 @@ func getTestScopedDockerSetup(t *testing.T) (types.TastoraDockerClient, string) } // SetupTestRethNode creates a single Reth node for testing purposes. -func SetupTestRethNode(t *testing.T) *reth.Node { +func SetupTestRethNode(t testing.TB) *reth.Node { t.Helper() ctx := context.Background() dockerCli, dockerNetID := getTestScopedDockerSetup(t) - n, err := reth.NewNodeBuilderWithTestName(t, fmt.Sprintf("%s-%s", t.Name(), randomString(6))). + testName := fmt.Sprintf("%s-%s", t.Name(), randomString(6)) + logger := zap.NewNop() + if testing.Verbose() { + logger = zaptest.NewLogger(t) + } + n, err := new(reth.NodeBuilder). + WithTestName(testName). + WithLogger(logger). + WithImage(reth.DefaultImage()). + WithBin("ev-reth"). WithDockerClient(dockerCli). WithDockerNetworkID(dockerNetID). WithGenesis([]byte(reth.DefaultEvolveGenesisJSON())). @@ -88,7 +99,7 @@ func SetupTestRethNode(t *testing.T) *reth.Node { } // waitForRethContainer waits for the Reth container to be ready by polling the provided endpoints with JWT authentication. -func waitForRethContainer(t *testing.T, jwtSecret, ethURL, engineURL string) error { +func waitForRethContainer(t testing.TB, jwtSecret, ethURL, engineURL string) error { t.Helper() client := &http.Client{Timeout: 100 * time.Millisecond} timer := time.NewTimer(30 * time.Second) diff --git a/execution/evm/test_helpers.go b/execution/evm/test_helpers.go index 1e97d446da..157f028b27 100644 --- a/execution/evm/test_helpers.go +++ b/execution/evm/test_helpers.go @@ -16,7 +16,7 @@ import ( // Transaction Helpers // GetRandomTransaction creates and signs a random Ethereum legacy transaction using the provided private key, recipient, chain ID, gas limit, and nonce. -func GetRandomTransaction(t *testing.T, privateKeyHex, toAddressHex, chainID string, gasLimit uint64, lastNonce *uint64) *types.Transaction { +func GetRandomTransaction(t testing.TB, privateKeyHex, toAddressHex, chainID string, gasLimit uint64, lastNonce *uint64) *types.Transaction { t.Helper() privateKey, err := crypto.HexToECDSA(privateKeyHex) require.NoError(t, err) diff --git a/test/e2e/evm_contract_bench_test.go b/test/e2e/evm_contract_bench_test.go new file mode 100644 index 0000000000..3c83a3c2a5 --- /dev/null +++ b/test/e2e/evm_contract_bench_test.go @@ -0,0 +1,104 @@ +//go:build evm + +package e2e + +import ( + "context" + "math/big" + "path/filepath" + "testing" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +// BenchmarkEvmContractRoundtrip measures the store → retrieve roundtrip latency +// against a real reth node with a pre-deployed contract. +// +// All transaction generation happens during setup. The timed loop exclusively +// measures: SendTransaction → wait for receipt → eth_call retrieve → verify. +// +// Run with (after building local-da and evm binaries): +// +// PATH="/path/to/binaries:$PATH" go test -tags evm \ +// -bench BenchmarkEvmContractRoundtrip -benchmem -benchtime=5x \ +// -run='^$' -timeout=10m --evm-binary=/path/to/evm . +func BenchmarkEvmContractRoundtrip(b *testing.B) { + workDir := b.TempDir() + sequencerHome := filepath.Join(workDir, "evm-bench-sequencer") + + client, _, cleanup := setupTestSequencer(b, sequencerHome) + defer cleanup() + + ctx := b.Context() + privateKey, err := crypto.HexToECDSA(TestPrivateKey) + require.NoError(b, err) + chainID, ok := new(big.Int).SetString(DefaultChainID, 10) + require.True(b, ok) + signer := types.NewEIP155Signer(chainID) + + // Deploy contract once during setup. + contractAddr, nonce := deployContract(b, ctx, client, StorageContractBytecode, 0, privateKey, chainID) + + // Pre-build signed store(42) transactions for all iterations. + storeData, err := hexutil.Decode("0x000000000000000000000000000000000000000000000000000000000000002a") + require.NoError(b, err) + + const maxIter = 1024 + signedTxs := make([]*types.Transaction, maxIter) + for i := range maxIter { + tx := types.NewTx(&types.LegacyTx{ + Nonce: nonce + uint64(i), + To: &contractAddr, + Value: big.NewInt(0), + Gas: 500000, + GasPrice: big.NewInt(30000000000), + Data: storeData, + }) + signedTxs[i], err = types.SignTx(tx, signer, privateKey) + require.NoError(b, err) + } + + expected := common.HexToHash("0x000000000000000000000000000000000000000000000000000000000000002a").Bytes() + callMsg := ethereum.CallMsg{To: &contractAddr, Data: []byte{}} + + b.ResetTimer() + b.ReportAllocs() + + var i int + for b.Loop() { + require.Less(b, i, maxIter, "increase maxIter for longer benchmark runs") + + // 1. Submit pre-signed store(42) transaction. + err = client.SendTransaction(ctx, signedTxs[i]) + require.NoError(b, err) + + // 2. Wait for inclusion. + waitForReceipt(b, ctx, client, signedTxs[i].Hash()) + + // 3. Retrieve and verify. + result, err := client.CallContract(ctx, callMsg, nil) + require.NoError(b, err) + require.Equal(b, expected, result, "retrieve() should return 42") + + i++ + } +} + +// waitForReceipt polls for a transaction receipt until it is available. +func waitForReceipt(t testing.TB, ctx context.Context, client *ethclient.Client, txHash common.Hash) *types.Receipt { + t.Helper() + var receipt *types.Receipt + var err error + require.Eventually(t, func() bool { + receipt, err = client.TransactionReceipt(ctx, txHash) + return err == nil && receipt != nil + }, 2*time.Second, 50*time.Millisecond, "transaction %s not included", txHash.Hex()) + return receipt +} diff --git a/test/e2e/evm_contract_e2e_test.go b/test/e2e/evm_contract_e2e_test.go index 0203ca6345..b6c988a85a 100644 --- a/test/e2e/evm_contract_e2e_test.go +++ b/test/e2e/evm_contract_e2e_test.go @@ -240,7 +240,7 @@ func TestEvmContractEvents(t *testing.T) { // setupTestSequencer sets up a single sequencer node for testing. // Returns the ethclient, genesis hash, and a cleanup function. -func setupTestSequencer(t *testing.T, homeDir string) (*ethclient.Client, string, func()) { +func setupTestSequencer(t testing.TB, homeDir string) (*ethclient.Client, string, func()) { sut := NewSystemUnderTest(t) genesisHash, seqEthURL := setupSequencerOnlyTest(t, sut, homeDir) @@ -257,7 +257,7 @@ func setupTestSequencer(t *testing.T, homeDir string) (*ethclient.Client, string // deployContract helps deploy a contract and waits for its inclusion. // Returns the deployed contract address and the next nonce. -func deployContract(t *testing.T, ctx context.Context, client *ethclient.Client, bytecodeStr string, nonce uint64, privateKey *ecdsa.PrivateKey, chainID *big.Int) (common.Address, uint64) { +func deployContract(t testing.TB, ctx context.Context, client *ethclient.Client, bytecodeStr string, nonce uint64, privateKey *ecdsa.PrivateKey, chainID *big.Int) (common.Address, uint64) { bytecode, err := hexutil.Decode("0x" + bytecodeStr) require.NoError(t, err) diff --git a/test/e2e/evm_test_common.go b/test/e2e/evm_test_common.go index d5a7215168..85c3abf629 100644 --- a/test/e2e/evm_test_common.go +++ b/test/e2e/evm_test_common.go @@ -55,7 +55,7 @@ func getAvailablePort() (int, net.Listener, error) { } // same as getAvailablePort but fails test if not successful -func mustGetAvailablePort(t *testing.T) int { +func mustGetAvailablePort(t testing.TB) int { t.Helper() port, listener, err := getAvailablePort() require.NoError(t, err) @@ -221,7 +221,7 @@ const ( // createPassphraseFile creates a temporary passphrase file and returns its path. // The file is created in the provided directory with secure permissions (0600). // If the directory doesn't exist, it will be created with 0755 permissions. -func createPassphraseFile(t *testing.T, dir string) string { +func createPassphraseFile(t testing.TB, dir string) string { t.Helper() // Ensure the directory exists err := os.MkdirAll(dir, 0755) @@ -236,7 +236,7 @@ func createPassphraseFile(t *testing.T, dir string) string { // createJWTSecretFile creates a temporary JWT secret file and returns its path. // The file is created in the provided directory with secure permissions (0600). // If the directory doesn't exist, it will be created with 0755 permissions. -func createJWTSecretFile(t *testing.T, dir, jwtSecret string) string { +func createJWTSecretFile(t testing.TB, dir, jwtSecret string) string { t.Helper() // Ensure the directory exists err := os.MkdirAll(dir, 0755) @@ -256,7 +256,7 @@ func createJWTSecretFile(t *testing.T, dir, jwtSecret string) string { // - rpcPort: Optional RPC port to use (if empty, uses default port) // // Returns: The full P2P address (e.g., /ip4/127.0.0.1/tcp/7676/p2p/12D3KooW...) -func getNodeP2PAddress(t *testing.T, sut *SystemUnderTest, nodeHome string, rpcPort ...string) string { +func getNodeP2PAddress(t testing.TB, sut *SystemUnderTest, nodeHome string, rpcPort ...string) string { t.Helper() // Build command arguments @@ -313,7 +313,7 @@ func getNodeP2PAddress(t *testing.T, sut *SystemUnderTest, nodeHome string, rpcP // - jwtSecret: JWT secret for authenticating with EVM engine // - genesisHash: Hash of the genesis block for chain validation // - endpoints: TestEndpoints struct containing unique port assignments -func setupSequencerNode(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) { +func setupSequencerNode(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) { t.Helper() // Create passphrase file @@ -357,7 +357,7 @@ func setupSequencerNode(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSe // setupSequencerNodeLazy initializes and starts the sequencer node in lazy mode. // In lazy mode, blocks are only produced when transactions are available, // not on a regular timer. -func setupSequencerNodeLazy(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) { +func setupSequencerNodeLazy(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) { t.Helper() // Create passphrase file @@ -417,7 +417,7 @@ func setupSequencerNodeLazy(t *testing.T, sut *SystemUnderTest, sequencerHome, j // - genesisHash: Hash of the genesis block for chain validation // - sequencerP2PAddress: P2P address of the sequencer node to connect to // - endpoints: TestEndpoints struct containing unique port assignments -func setupFullNode(t *testing.T, sut *SystemUnderTest, fullNodeHome, sequencerHome, fullNodeJwtSecret, genesisHash, sequencerP2PAddress string, endpoints *TestEndpoints) { +func setupFullNode(t testing.TB, sut *SystemUnderTest, fullNodeHome, sequencerHome, fullNodeJwtSecret, genesisHash, sequencerP2PAddress string, endpoints *TestEndpoints) { t.Helper() // Initialize full node @@ -478,7 +478,7 @@ var globalNonce uint64 = 0 // // This is used in full node sync tests to verify that both nodes // include the same transaction in the same block number. -func submitTransactionAndGetBlockNumber(t *testing.T, sequencerClients ...*ethclient.Client) (common.Hash, uint64) { +func submitTransactionAndGetBlockNumber(t testing.TB, sequencerClients ...*ethclient.Client) (common.Hash, uint64) { t.Helper() // Submit transaction to sequencer EVM with unique nonce @@ -512,7 +512,7 @@ func submitTransactionAndGetBlockNumber(t *testing.T, sequencerClients ...*ethcl // - daPort: optional DA port to use (if empty, uses default) // // Returns: jwtSecret, fullNodeJwtSecret (empty if needsFullNode=false), genesisHash -func setupCommonEVMTest(t *testing.T, sut *SystemUnderTest, needsFullNode bool, _ ...string) (string, string, string, *TestEndpoints) { +func setupCommonEVMTest(t testing.TB, sut *SystemUnderTest, needsFullNode bool, _ ...string) (string, string, string, *TestEndpoints) { t.Helper() // Reset global nonce for each test to ensure clean state @@ -570,7 +570,7 @@ func setupCommonEVMTest(t *testing.T, sut *SystemUnderTest, needsFullNode bool, // - blockHeight: Height of the block to retrieve (use nil for latest) // // Returns: block hash, state root, transaction count, block number, and error -func checkBlockInfoAt(t *testing.T, ethURL string, blockHeight *uint64) (common.Hash, common.Hash, int, uint64, error) { +func checkBlockInfoAt(t testing.TB, ethURL string, blockHeight *uint64) (common.Hash, common.Hash, int, uint64, error) { t.Helper() ctx := context.Background() @@ -613,7 +613,7 @@ func checkBlockInfoAt(t *testing.T, ethURL string, blockHeight *uint64) (common. // - nodeHome: Directory path for sequencer node data // // Returns: genesisHash for the sequencer -func setupSequencerOnlyTest(t *testing.T, sut *SystemUnderTest, nodeHome string) (string, string) { +func setupSequencerOnlyTest(t testing.TB, sut *SystemUnderTest, nodeHome string) (string, string) { t.Helper() // Use common setup (no full node needed) @@ -635,7 +635,7 @@ func setupSequencerOnlyTest(t *testing.T, sut *SystemUnderTest, nodeHome string) // - sequencerHome: Directory path for sequencer node data // - jwtSecret: JWT secret for sequencer's EVM engine authentication // - genesisHash: Hash of the genesis block for chain validation -func restartDAAndSequencer(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) { +func restartDAAndSequencer(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) { t.Helper() // First restart the local DA @@ -685,7 +685,7 @@ func restartDAAndSequencer(t *testing.T, sut *SystemUnderTest, sequencerHome, jw // - sequencerHome: Directory path for sequencer node data // - jwtSecret: JWT secret for sequencer's EVM engine authentication // - genesisHash: Hash of the genesis block for chain validation -func restartDAAndSequencerLazy(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) { +func restartDAAndSequencerLazy(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) { t.Helper() // First restart the local DA @@ -736,7 +736,7 @@ func restartDAAndSequencerLazy(t *testing.T, sut *SystemUnderTest, sequencerHome // - sequencerHome: Directory path for sequencer node data // - jwtSecret: JWT secret for sequencer's EVM engine authentication // - genesisHash: Hash of the genesis block for chain validation -func restartSequencerNode(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string) { +func restartSequencerNode(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string) { t.Helper() // Start sequencer node (without init - node already exists) @@ -772,7 +772,7 @@ func restartSequencerNode(t *testing.T, sut *SystemUnderTest, sequencerHome, jwt // - nodeName: Human-readable name for logging (e.g., "sequencer", "full node") // // This function ensures that during lazy mode idle periods, no automatic block production occurs. -func verifyNoBlockProduction(t *testing.T, client *ethclient.Client, duration time.Duration, nodeName string) { +func verifyNoBlockProduction(t testing.TB, client *ethclient.Client, duration time.Duration, nodeName string) { t.Helper() ctx := context.Background() diff --git a/test/e2e/sut_helper.go b/test/e2e/sut_helper.go index beba2b6195..f5783da8bb 100644 --- a/test/e2e/sut_helper.go +++ b/test/e2e/sut_helper.go @@ -33,7 +33,7 @@ var WorkDir = "." // SystemUnderTest is used to manage processes and logs during test execution. type SystemUnderTest struct { - t *testing.T + t testing.TB outBuff *ring.Ring errBuff *ring.Ring @@ -45,7 +45,7 @@ type SystemUnderTest struct { } // NewSystemUnderTest constructor -func NewSystemUnderTest(t *testing.T) *SystemUnderTest { +func NewSystemUnderTest(t testing.TB) *SystemUnderTest { r := &SystemUnderTest{ t: t, pids: make(map[int]struct{}), @@ -103,7 +103,7 @@ func (s *SystemUnderTest) ExecCmdWithLogPrefix(prefix, cmd string, args ...strin // AwaitNodeUp waits until a node is operational by checking both liveness and readiness. // Fails tests when node is not up within the specified timeout. -func (s *SystemUnderTest) AwaitNodeUp(t *testing.T, rpcAddr string, timeout time.Duration) { +func (s *SystemUnderTest) AwaitNodeUp(t testing.TB, rpcAddr string, timeout time.Duration) { t.Helper() t.Logf("Await node is up: %s", rpcAddr) require.EventuallyWithT(t, func(t *assert.CollectT) { @@ -120,7 +120,7 @@ func (s *SystemUnderTest) AwaitNodeUp(t *testing.T, rpcAddr string, timeout time } // AwaitNodeLive waits until a node is alive (liveness check only). -func (s *SystemUnderTest) AwaitNodeLive(t *testing.T, rpcAddr string, timeout time.Duration) { +func (s *SystemUnderTest) AwaitNodeLive(t testing.TB, rpcAddr string, timeout time.Duration) { t.Helper() t.Logf("Await node is live: %s", rpcAddr) require.EventuallyWithT(t, func(t *assert.CollectT) { @@ -132,7 +132,7 @@ func (s *SystemUnderTest) AwaitNodeLive(t *testing.T, rpcAddr string, timeout ti } // AwaitNBlocks waits until the node has produced at least `n` blocks. -func (s *SystemUnderTest) AwaitNBlocks(t *testing.T, n uint64, rpcAddr string, timeout time.Duration) { +func (s *SystemUnderTest) AwaitNBlocks(t testing.TB, n uint64, rpcAddr string, timeout time.Duration) { t.Helper() ctx, done := context.WithTimeout(context.Background(), timeout) defer done() @@ -344,7 +344,7 @@ func locateExecutable(file string) string { } // MustCopyFile copies the file from the source path `src` to the destination path `dest` and returns an open file handle to `dest`. -func MustCopyFile(t *testing.T, src, dest string) *os.File { +func MustCopyFile(t testing.TB, src, dest string) *os.File { t.Helper() in, err := os.Open(src) // nolint: gosec // used by tests only require.NoError(t, err) @@ -362,11 +362,11 @@ func MustCopyFile(t *testing.T, src, dest string) *os.File { } // NodeID generates and returns the peer ID from the node's private key. -func NodeID(t *testing.T, nodeDir string) peer.ID { +func NodeID(t testing.TB, nodeDir string) peer.ID { t.Helper() node1Key, err := key.LoadNodeKey(filepath.Join(nodeDir, "config")) require.NoError(t, err) node1ID, err := peer.IDFromPrivateKey(node1Key.PrivKey) require.NoError(t, err) return node1ID -} \ No newline at end of file +} From c3228387fc0f4ff6c21222662a777883625b93f0 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Tue, 17 Feb 2026 19:08:13 +0100 Subject: [PATCH 2/8] Capture otel --- test/e2e/evm_contract_bench_test.go | 198 +++++++++++++++++++++++++++- test/e2e/evm_contract_e2e_test.go | 4 +- test/e2e/evm_test_common.go | 7 +- test/e2e/go.mod | 4 +- 4 files changed, 202 insertions(+), 11 deletions(-) diff --git a/test/e2e/evm_contract_bench_test.go b/test/e2e/evm_contract_bench_test.go index 3c83a3c2a5..b03e41e2bc 100644 --- a/test/e2e/evm_contract_bench_test.go +++ b/test/e2e/evm_contract_bench_test.go @@ -4,8 +4,15 @@ package e2e import ( "context" + "encoding/json" + "fmt" + "io" "math/big" + "net" + "net/http" "path/filepath" + "sort" + "sync" "testing" "time" @@ -16,24 +23,40 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + collpb "go.opentelemetry.io/proto/otlp/collector/trace/v1" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // BenchmarkEvmContractRoundtrip measures the store → retrieve roundtrip latency // against a real reth node with a pre-deployed contract. // -// All transaction generation happens during setup. The timed loop exclusively -// measures: SendTransaction → wait for receipt → eth_call retrieve → verify. +// The node is started with OpenTelemetry tracing enabled, exporting to an +// in-process OTLP/HTTP receiver. After the timed loop, the collected spans are +// aggregated into a hierarchical timing report showing where time is spent +// inside ev-node (Engine API calls, executor, sequencer, etc). // // Run with (after building local-da and evm binaries): // // PATH="/path/to/binaries:$PATH" go test -tags evm \ // -bench BenchmarkEvmContractRoundtrip -benchmem -benchtime=5x \ -// -run='^$' -timeout=10m --evm-binary=/path/to/evm . +// -run='^$' -timeout=10m -v --evm-binary=/path/to/evm . func BenchmarkEvmContractRoundtrip(b *testing.B) { workDir := b.TempDir() sequencerHome := filepath.Join(workDir, "evm-bench-sequencer") - client, _, cleanup := setupTestSequencer(b, sequencerHome) + // Start an in-process OTLP/HTTP receiver to collect traces from ev-node. + collector := newOTLPCollector(b) + defer collector.close() + + // Start sequencer with tracing enabled, exporting to our in-process collector. + client, _, cleanup := setupTestSequencer(b, sequencerHome, + "--evnode.instrumentation.tracing=true", + "--evnode.instrumentation.tracing_endpoint", collector.endpoint(), + "--evnode.instrumentation.tracing_sample_rate", "1.0", + "--evnode.instrumentation.tracing_service_name", "ev-node-bench", + ) defer cleanup() ctx := b.Context() @@ -89,6 +112,173 @@ func BenchmarkEvmContractRoundtrip(b *testing.B) { i++ } + + b.StopTimer() + + // Give the node a moment to flush pending span batches. + time.Sleep(2 * time.Second) + + // Print the trace breakdown from the collected spans. + printCollectedTraceReport(b, collector) +} + +// --- In-process OTLP/HTTP Collector --- + +// otlpCollector is a lightweight OTLP/HTTP receiver that collects trace spans +// in memory. It serves the /v1/traces endpoint that the node's OTLP exporter +// posts protobuf-encoded ExportTraceServiceRequest messages to. +type otlpCollector struct { + mu sync.Mutex + spans []*tracepb.Span + server *http.Server + addr string +} + +func newOTLPCollector(t testing.TB) *otlpCollector { + t.Helper() + + c := &otlpCollector{} + + mux := http.NewServeMux() + mux.HandleFunc("/v1/traces", c.handleTraces) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + c.addr = listener.Addr().String() + + c.server = &http.Server{Handler: mux} + go func() { _ = c.server.Serve(listener) }() + + t.Logf("OTLP collector listening on %s", c.addr) + return c +} + +func (c *otlpCollector) endpoint() string { + return "http://" + c.addr +} + +func (c *otlpCollector) close() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = c.server.Shutdown(ctx) +} + +func (c *otlpCollector) handleTraces(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Try protobuf first (default for otlptracehttp). + var req collpb.ExportTraceServiceRequest + if err := proto.Unmarshal(body, &req); err != nil { + // Fallback: try JSON (some configurations use JSON encoding). + if jsonErr := json.Unmarshal(body, &req); jsonErr != nil { + http.Error(w, fmt.Sprintf("proto: %v; json: %v", err, jsonErr), http.StatusBadRequest) + return + } + } + + c.mu.Lock() + for _, rs := range req.GetResourceSpans() { + for _, ss := range rs.GetScopeSpans() { + c.spans = append(c.spans, ss.GetSpans()...) + } + } + c.mu.Unlock() + + // Respond with an empty ExportTraceServiceResponse (protobuf). + resp := &collpb.ExportTraceServiceResponse{} + out, _ := proto.Marshal(resp) + w.Header().Set("Content-Type", "application/x-protobuf") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(out) +} + +func (c *otlpCollector) getSpans() []*tracepb.Span { + c.mu.Lock() + defer c.mu.Unlock() + cp := make([]*tracepb.Span, len(c.spans)) + copy(cp, c.spans) + return cp +} + +// printCollectedTraceReport aggregates collected spans by operation name and +// prints a timing breakdown. +func printCollectedTraceReport(b testing.TB, collector *otlpCollector) { + b.Helper() + + spans := collector.getSpans() + if len(spans) == 0 { + b.Logf("WARNING: no spans collected from ev-node") + return + } + + type stats struct { + count int + total time.Duration + min time.Duration + max time.Duration + } + m := make(map[string]*stats) + + for _, span := range spans { + // Duration: end - start in nanoseconds. + d := time.Duration(span.GetEndTimeUnixNano()-span.GetStartTimeUnixNano()) * time.Nanosecond + if d <= 0 { + continue + } + name := span.GetName() + s, ok := m[name] + if !ok { + s = &stats{min: d, max: d} + m[name] = s + } + s.count++ + s.total += d + if d < s.min { + s.min = d + } + if d > s.max { + s.max = d + } + } + + // Sort by total time descending. + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + sort.Slice(names, func(i, j int) bool { + return m[names[i]].total > m[names[j]].total + }) + + // Calculate overall total for percentages. + var overallTotal time.Duration + for _, s := range m { + overallTotal += s.total + } + + b.Logf("\n--- ev-node Trace Breakdown (%d spans collected) ---", len(spans)) + b.Logf("%-40s %6s %12s %12s %12s %7s", "OPERATION", "COUNT", "AVG", "MIN", "MAX", "% TOTAL") + for _, name := range names { + s := m[name] + avg := s.total / time.Duration(s.count) + pct := float64(s.total) / float64(overallTotal) * 100 + b.Logf("%-40s %6d %12s %12s %12s %6.1f%%", name, s.count, avg, s.min, s.max, pct) + } + + b.Logf("\n--- Time Distribution ---") + for _, name := range names { + s := m[name] + pct := float64(s.total) / float64(overallTotal) * 100 + bar := "" + for range int(pct / 2) { + bar += "█" + } + b.Logf("%-40s %5.1f%% %s", name, pct, bar) + } } // waitForReceipt polls for a transaction receipt until it is available. diff --git a/test/e2e/evm_contract_e2e_test.go b/test/e2e/evm_contract_e2e_test.go index b6c988a85a..477b0801be 100644 --- a/test/e2e/evm_contract_e2e_test.go +++ b/test/e2e/evm_contract_e2e_test.go @@ -240,10 +240,10 @@ func TestEvmContractEvents(t *testing.T) { // setupTestSequencer sets up a single sequencer node for testing. // Returns the ethclient, genesis hash, and a cleanup function. -func setupTestSequencer(t testing.TB, homeDir string) (*ethclient.Client, string, func()) { +func setupTestSequencer(t testing.TB, homeDir string, extraArgs ...string) (*ethclient.Client, string, func()) { sut := NewSystemUnderTest(t) - genesisHash, seqEthURL := setupSequencerOnlyTest(t, sut, homeDir) + genesisHash, seqEthURL := setupSequencerOnlyTest(t, sut, homeDir, extraArgs...) t.Logf("Sequencer started at %s (Genesis: %s)", seqEthURL, genesisHash) client, err := ethclient.Dial(seqEthURL) diff --git a/test/e2e/evm_test_common.go b/test/e2e/evm_test_common.go index 85c3abf629..33518097f9 100644 --- a/test/e2e/evm_test_common.go +++ b/test/e2e/evm_test_common.go @@ -313,7 +313,7 @@ func getNodeP2PAddress(t testing.TB, sut *SystemUnderTest, nodeHome string, rpcP // - jwtSecret: JWT secret for authenticating with EVM engine // - genesisHash: Hash of the genesis block for chain validation // - endpoints: TestEndpoints struct containing unique port assignments -func setupSequencerNode(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) { +func setupSequencerNode(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints, extraArgs ...string) { t.Helper() // Create passphrase file @@ -350,6 +350,7 @@ func setupSequencerNode(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSe "--evm.engine-url", endpoints.GetSequencerEngineURL(), "--evm.eth-url", endpoints.GetSequencerEthURL(), } + args = append(args, extraArgs...) sut.ExecCmd(evmSingleBinaryPath, args...) sut.AwaitNodeUp(t, endpoints.GetRollkitRPCAddress(), NodeStartupTimeout) } @@ -613,14 +614,14 @@ func checkBlockInfoAt(t testing.TB, ethURL string, blockHeight *uint64) (common. // - nodeHome: Directory path for sequencer node data // // Returns: genesisHash for the sequencer -func setupSequencerOnlyTest(t testing.TB, sut *SystemUnderTest, nodeHome string) (string, string) { +func setupSequencerOnlyTest(t testing.TB, sut *SystemUnderTest, nodeHome string, extraArgs ...string) (string, string) { t.Helper() // Use common setup (no full node needed) jwtSecret, _, genesisHash, endpoints := setupCommonEVMTest(t, sut, false) // Initialize and start sequencer node - setupSequencerNode(t, sut, nodeHome, jwtSecret, genesisHash, endpoints) + setupSequencerNode(t, sut, nodeHome, jwtSecret, genesisHash, endpoints, extraArgs...) t.Log("Sequencer node is up") return genesisHash, endpoints.GetSequencerEthURL() diff --git a/test/e2e/go.mod b/test/e2e/go.mod index 9ef0dae15b..42d0de4b83 100644 --- a/test/e2e/go.mod +++ b/test/e2e/go.mod @@ -15,6 +15,8 @@ require ( github.com/libp2p/go-libp2p v0.47.0 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/sdk v1.40.0 google.golang.org/protobuf v1.36.11 ) @@ -288,11 +290,9 @@ require ( go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/dig v1.19.0 // indirect From b7ed06aa5b3770b8c1c9fa303073e885350412a6 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Wed, 18 Feb 2026 10:29:14 +0100 Subject: [PATCH 3/8] Go mod tidy --- execution/evm/test/go.mod | 2 +- test/e2e/go.mod | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/execution/evm/test/go.mod b/execution/evm/test/go.mod index 744c263775..e9f8d7129e 100644 --- a/execution/evm/test/go.mod +++ b/execution/evm/test/go.mod @@ -10,6 +10,7 @@ require ( github.com/ipfs/go-datastore v0.9.0 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 + go.uber.org/zap v1.27.1 ) require ( @@ -179,7 +180,6 @@ require ( go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect diff --git a/test/e2e/go.mod b/test/e2e/go.mod index 42d0de4b83..53ffbd1834 100644 --- a/test/e2e/go.mod +++ b/test/e2e/go.mod @@ -15,8 +15,7 @@ require ( github.com/libp2p/go-libp2p v0.47.0 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 - go.opentelemetry.io/otel v1.40.0 - go.opentelemetry.io/otel/sdk v1.40.0 + go.opentelemetry.io/proto/otlp v1.9.0 google.golang.org/protobuf v1.36.11 ) @@ -290,11 +289,12 @@ require ( go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/dig v1.19.0 // indirect go.uber.org/fx v1.24.0 // indirect go.uber.org/mock v0.6.0 // indirect From c7a5914df0b5400db5c26aa60bbb99437d745f9a Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Wed, 18 Feb 2026 10:29:41 +0100 Subject: [PATCH 4/8] Enable benchmark on PRs - revert before merge --- .github/workflows/benchmark.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index e34f615027..88841b4425 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -5,6 +5,7 @@ permissions: {} push: branches: - main + pull_request: workflow_dispatch: jobs: @@ -35,7 +36,7 @@ jobs: name: EVM Contract Roundtrip tool: 'go' output-file-path: test/e2e/output.txt - auto-push: true + auto-push: ${{ github.event_name != 'pull_request' }} github-token: ${{ secrets.GITHUB_TOKEN }} alert-threshold: '150%' fail-on-alert: true From 3b74695b3b8d389271a0c122f656a2a7199ef382 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Wed, 18 Feb 2026 10:49:15 +0100 Subject: [PATCH 5/8] Push benchmark results on PR - revert later --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 88841b4425..245d2bd647 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -36,7 +36,7 @@ jobs: name: EVM Contract Roundtrip tool: 'go' output-file-path: test/e2e/output.txt - auto-push: ${{ github.event_name != 'pull_request' }} + auto-push: true # ${{ github.event_name != 'pull_request' }} github-token: ${{ secrets.GITHUB_TOKEN }} alert-threshold: '150%' fail-on-alert: true From d2512dd0e01ccf461e59c94906aafd5397e92bb0 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Wed, 18 Feb 2026 14:22:46 +0100 Subject: [PATCH 6/8] Review feedback --- test/e2e/evm_contract_bench_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/evm_contract_bench_test.go b/test/e2e/evm_contract_bench_test.go index b03e41e2bc..70b40cd8b1 100644 --- a/test/e2e/evm_contract_bench_test.go +++ b/test/e2e/evm_contract_bench_test.go @@ -48,7 +48,7 @@ func BenchmarkEvmContractRoundtrip(b *testing.B) { // Start an in-process OTLP/HTTP receiver to collect traces from ev-node. collector := newOTLPCollector(b) - defer collector.close() + defer collector.close() // nolint: errcheck // test only // Start sequencer with tracing enabled, exporting to our in-process collector. client, _, cleanup := setupTestSequencer(b, sequencerHome, @@ -157,10 +157,10 @@ func (c *otlpCollector) endpoint() string { return "http://" + c.addr } -func (c *otlpCollector) close() { +func (c *otlpCollector) close() error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - _ = c.server.Shutdown(ctx) + return c.server.Shutdown(ctx) } func (c *otlpCollector) handleTraces(w http.ResponseWriter, r *http.Request) { From e747d75dbad7c46a14a3a8d3b712378fccfed095 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Wed, 18 Feb 2026 14:49:14 +0100 Subject: [PATCH 7/8] Revert "Push benchmark results on PR - revert later" This reverts commit 3b74695b3b8d389271a0c122f656a2a7199ef382. --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 245d2bd647..88841b4425 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -36,7 +36,7 @@ jobs: name: EVM Contract Roundtrip tool: 'go' output-file-path: test/e2e/output.txt - auto-push: true # ${{ github.event_name != 'pull_request' }} + auto-push: ${{ github.event_name != 'pull_request' }} github-token: ${{ secrets.GITHUB_TOKEN }} alert-threshold: '150%' fail-on-alert: true From b3b088172da00d2dc909cc4d4f139d50f94573f8 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Wed, 18 Feb 2026 14:49:21 +0100 Subject: [PATCH 8/8] Revert "Enable benchmark on PRs - revert before merge" This reverts commit c7a5914df0b5400db5c26aa60bbb99437d745f9a. --- .github/workflows/benchmark.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 88841b4425..e34f615027 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -5,7 +5,6 @@ permissions: {} push: branches: - main - pull_request: workflow_dispatch: jobs: @@ -36,7 +35,7 @@ jobs: name: EVM Contract Roundtrip tool: 'go' output-file-path: test/e2e/output.txt - auto-push: ${{ github.event_name != 'pull_request' }} + auto-push: true github-token: ${{ secrets.GITHUB_TOKEN }} alert-threshold: '150%' fail-on-alert: true