Skip to content

Commit aeaf7fa

Browse files
committed
feat: introduce EVM contract benchmarking with new tests and a GitHub Actions workflow.
1 parent d4394f6 commit aeaf7fa

File tree

7 files changed

+187
-30
lines changed

7 files changed

+187
-30
lines changed

.github/workflows/benchmark.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
name: Benchmarks
3+
permissions: {}
4+
"on":
5+
push:
6+
branches:
7+
- main
8+
workflow_dispatch:
9+
10+
jobs:
11+
evm-benchmark:
12+
name: EVM Contract Benchmark
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 30
15+
permissions:
16+
contents: write
17+
issues: write
18+
steps:
19+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
20+
- name: Set up Go
21+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
22+
with:
23+
go-version-file: ./go.mod
24+
- name: Set up Docker Buildx
25+
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
26+
- name: Build binaries
27+
run: make build-evm build-da
28+
- name: Run EVM benchmarks
29+
run: |
30+
cd test/e2e && go test -tags evm -bench=. -benchmem -run='^$' \
31+
-timeout=10m --evm-binary=../../build/evm | tee output.txt
32+
- name: Store benchmark result
33+
uses: benchmark-action/github-action-benchmark@4bdcce38c94cec68da58d012ac24b7b1155efe8b # v1.20.7
34+
with:
35+
name: EVM Contract Roundtrip
36+
tool: 'go'
37+
output-file-path: test/e2e/output.txt
38+
auto-push: true
39+
github-token: ${{ secrets.GITHUB_TOKEN }}
40+
alert-threshold: '150%'
41+
fail-on-alert: true
42+
comment-on-alert: true

execution/evm/test/test_helpers.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"github.com/celestiaorg/tastora/framework/types"
1919
"github.com/golang-jwt/jwt/v5"
2020
"github.com/stretchr/testify/require"
21+
"go.uber.org/zap"
22+
"go.uber.org/zap/zaptest"
2123
)
2224

2325
// Test-scoped Docker client/network mapping to avoid conflicts between tests
@@ -39,7 +41,7 @@ func randomString(n int) string {
3941
}
4042

4143
// getTestScopedDockerSetup returns a Docker client and network ID that are scoped to the specific test.
42-
func getTestScopedDockerSetup(t *testing.T) (types.TastoraDockerClient, string) {
44+
func getTestScopedDockerSetup(t testing.TB) (types.TastoraDockerClient, string) {
4345
t.Helper()
4446

4547
testKey := t.Name()
@@ -59,13 +61,22 @@ func getTestScopedDockerSetup(t *testing.T) (types.TastoraDockerClient, string)
5961
}
6062

6163
// SetupTestRethNode creates a single Reth node for testing purposes.
62-
func SetupTestRethNode(t *testing.T) *reth.Node {
64+
func SetupTestRethNode(t testing.TB) *reth.Node {
6365
t.Helper()
6466
ctx := context.Background()
6567

6668
dockerCli, dockerNetID := getTestScopedDockerSetup(t)
6769

68-
n, err := reth.NewNodeBuilderWithTestName(t, fmt.Sprintf("%s-%s", t.Name(), randomString(6))).
70+
testName := fmt.Sprintf("%s-%s", t.Name(), randomString(6))
71+
logger := zap.NewNop()
72+
if testing.Verbose() {
73+
logger = zaptest.NewLogger(t)
74+
}
75+
n, err := new(reth.NodeBuilder).
76+
WithTestName(testName).
77+
WithLogger(logger).
78+
WithImage(reth.DefaultImage()).
79+
WithBin("ev-reth").
6980
WithDockerClient(dockerCli).
7081
WithDockerNetworkID(dockerNetID).
7182
WithGenesis([]byte(reth.DefaultEvolveGenesisJSON())).
@@ -88,7 +99,7 @@ func SetupTestRethNode(t *testing.T) *reth.Node {
8899
}
89100

90101
// waitForRethContainer waits for the Reth container to be ready by polling the provided endpoints with JWT authentication.
91-
func waitForRethContainer(t *testing.T, jwtSecret, ethURL, engineURL string) error {
102+
func waitForRethContainer(t testing.TB, jwtSecret, ethURL, engineURL string) error {
92103
t.Helper()
93104
client := &http.Client{Timeout: 100 * time.Millisecond}
94105
timer := time.NewTimer(30 * time.Second)

execution/evm/test_helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
// Transaction Helpers
1717

1818
// GetRandomTransaction creates and signs a random Ethereum legacy transaction using the provided private key, recipient, chain ID, gas limit, and nonce.
19-
func GetRandomTransaction(t *testing.T, privateKeyHex, toAddressHex, chainID string, gasLimit uint64, lastNonce *uint64) *types.Transaction {
19+
func GetRandomTransaction(t testing.TB, privateKeyHex, toAddressHex, chainID string, gasLimit uint64, lastNonce *uint64) *types.Transaction {
2020
t.Helper()
2121
privateKey, err := crypto.HexToECDSA(privateKeyHex)
2222
require.NoError(t, err)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//go:build evm
2+
3+
package e2e
4+
5+
import (
6+
"context"
7+
"math/big"
8+
"path/filepath"
9+
"testing"
10+
"time"
11+
12+
"github.com/ethereum/go-ethereum"
13+
"github.com/ethereum/go-ethereum/common"
14+
"github.com/ethereum/go-ethereum/common/hexutil"
15+
"github.com/ethereum/go-ethereum/core/types"
16+
"github.com/ethereum/go-ethereum/crypto"
17+
"github.com/ethereum/go-ethereum/ethclient"
18+
"github.com/stretchr/testify/require"
19+
)
20+
21+
// BenchmarkEvmContractRoundtrip measures the store → retrieve roundtrip latency
22+
// against a real reth node with a pre-deployed contract.
23+
//
24+
// All transaction generation happens during setup. The timed loop exclusively
25+
// measures: SendTransaction → wait for receipt → eth_call retrieve → verify.
26+
//
27+
// Run with (after building local-da and evm binaries):
28+
//
29+
// PATH="/path/to/binaries:$PATH" go test -tags evm \
30+
// -bench BenchmarkEvmContractRoundtrip -benchmem -benchtime=5x \
31+
// -run='^$' -timeout=10m --evm-binary=/path/to/evm .
32+
func BenchmarkEvmContractRoundtrip(b *testing.B) {
33+
workDir := b.TempDir()
34+
sequencerHome := filepath.Join(workDir, "evm-bench-sequencer")
35+
36+
client, _, cleanup := setupTestSequencer(b, sequencerHome)
37+
defer cleanup()
38+
39+
ctx := b.Context()
40+
privateKey, err := crypto.HexToECDSA(TestPrivateKey)
41+
require.NoError(b, err)
42+
chainID, ok := new(big.Int).SetString(DefaultChainID, 10)
43+
require.True(b, ok)
44+
signer := types.NewEIP155Signer(chainID)
45+
46+
// Deploy contract once during setup.
47+
contractAddr, nonce := deployContract(b, ctx, client, StorageContractBytecode, 0, privateKey, chainID)
48+
49+
// Pre-build signed store(42) transactions for all iterations.
50+
storeData, err := hexutil.Decode("0x000000000000000000000000000000000000000000000000000000000000002a")
51+
require.NoError(b, err)
52+
53+
const maxIter = 1024
54+
signedTxs := make([]*types.Transaction, maxIter)
55+
for i := range maxIter {
56+
tx := types.NewTx(&types.LegacyTx{
57+
Nonce: nonce + uint64(i),
58+
To: &contractAddr,
59+
Value: big.NewInt(0),
60+
Gas: 500000,
61+
GasPrice: big.NewInt(30000000000),
62+
Data: storeData,
63+
})
64+
signedTxs[i], err = types.SignTx(tx, signer, privateKey)
65+
require.NoError(b, err)
66+
}
67+
68+
expected := common.HexToHash("0x000000000000000000000000000000000000000000000000000000000000002a").Bytes()
69+
callMsg := ethereum.CallMsg{To: &contractAddr, Data: []byte{}}
70+
71+
b.ResetTimer()
72+
b.ReportAllocs()
73+
74+
var i int
75+
for b.Loop() {
76+
require.Less(b, i, maxIter, "increase maxIter for longer benchmark runs")
77+
78+
// 1. Submit pre-signed store(42) transaction.
79+
err = client.SendTransaction(ctx, signedTxs[i])
80+
require.NoError(b, err)
81+
82+
// 2. Wait for inclusion.
83+
waitForReceipt(b, ctx, client, signedTxs[i].Hash())
84+
85+
// 3. Retrieve and verify.
86+
result, err := client.CallContract(ctx, callMsg, nil)
87+
require.NoError(b, err)
88+
require.Equal(b, expected, result, "retrieve() should return 42")
89+
90+
i++
91+
}
92+
}
93+
94+
// waitForReceipt polls for a transaction receipt until it is available.
95+
func waitForReceipt(t testing.TB, ctx context.Context, client *ethclient.Client, txHash common.Hash) *types.Receipt {
96+
t.Helper()
97+
var receipt *types.Receipt
98+
var err error
99+
require.Eventually(t, func() bool {
100+
receipt, err = client.TransactionReceipt(ctx, txHash)
101+
return err == nil && receipt != nil
102+
}, 2*time.Second, 50*time.Millisecond, "transaction %s not included", txHash.Hex())
103+
return receipt
104+
}

test/e2e/evm_contract_e2e_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ func TestEvmContractEvents(t *testing.T) {
240240

241241
// setupTestSequencer sets up a single sequencer node for testing.
242242
// Returns the ethclient, genesis hash, and a cleanup function.
243-
func setupTestSequencer(t *testing.T, homeDir string) (*ethclient.Client, string, func()) {
243+
func setupTestSequencer(t testing.TB, homeDir string) (*ethclient.Client, string, func()) {
244244
sut := NewSystemUnderTest(t)
245245

246246
genesisHash, seqEthURL := setupSequencerOnlyTest(t, sut, homeDir)
@@ -257,7 +257,7 @@ func setupTestSequencer(t *testing.T, homeDir string) (*ethclient.Client, string
257257

258258
// deployContract helps deploy a contract and waits for its inclusion.
259259
// Returns the deployed contract address and the next nonce.
260-
func deployContract(t *testing.T, ctx context.Context, client *ethclient.Client, bytecodeStr string, nonce uint64, privateKey *ecdsa.PrivateKey, chainID *big.Int) (common.Address, uint64) {
260+
func deployContract(t testing.TB, ctx context.Context, client *ethclient.Client, bytecodeStr string, nonce uint64, privateKey *ecdsa.PrivateKey, chainID *big.Int) (common.Address, uint64) {
261261
bytecode, err := hexutil.Decode("0x" + bytecodeStr)
262262
require.NoError(t, err)
263263

test/e2e/evm_test_common.go

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func getAvailablePort() (int, net.Listener, error) {
5555
}
5656

5757
// same as getAvailablePort but fails test if not successful
58-
func mustGetAvailablePort(t *testing.T) int {
58+
func mustGetAvailablePort(t testing.TB) int {
5959
t.Helper()
6060
port, listener, err := getAvailablePort()
6161
require.NoError(t, err)
@@ -221,7 +221,7 @@ const (
221221
// createPassphraseFile creates a temporary passphrase file and returns its path.
222222
// The file is created in the provided directory with secure permissions (0600).
223223
// If the directory doesn't exist, it will be created with 0755 permissions.
224-
func createPassphraseFile(t *testing.T, dir string) string {
224+
func createPassphraseFile(t testing.TB, dir string) string {
225225
t.Helper()
226226
// Ensure the directory exists
227227
err := os.MkdirAll(dir, 0755)
@@ -236,7 +236,7 @@ func createPassphraseFile(t *testing.T, dir string) string {
236236
// createJWTSecretFile creates a temporary JWT secret file and returns its path.
237237
// The file is created in the provided directory with secure permissions (0600).
238238
// If the directory doesn't exist, it will be created with 0755 permissions.
239-
func createJWTSecretFile(t *testing.T, dir, jwtSecret string) string {
239+
func createJWTSecretFile(t testing.TB, dir, jwtSecret string) string {
240240
t.Helper()
241241
// Ensure the directory exists
242242
err := os.MkdirAll(dir, 0755)
@@ -256,7 +256,7 @@ func createJWTSecretFile(t *testing.T, dir, jwtSecret string) string {
256256
// - rpcPort: Optional RPC port to use (if empty, uses default port)
257257
//
258258
// Returns: The full P2P address (e.g., /ip4/127.0.0.1/tcp/7676/p2p/12D3KooW...)
259-
func getNodeP2PAddress(t *testing.T, sut *SystemUnderTest, nodeHome string, rpcPort ...string) string {
259+
func getNodeP2PAddress(t testing.TB, sut *SystemUnderTest, nodeHome string, rpcPort ...string) string {
260260
t.Helper()
261261

262262
// Build command arguments
@@ -313,7 +313,7 @@ func getNodeP2PAddress(t *testing.T, sut *SystemUnderTest, nodeHome string, rpcP
313313
// - jwtSecret: JWT secret for authenticating with EVM engine
314314
// - genesisHash: Hash of the genesis block for chain validation
315315
// - endpoints: TestEndpoints struct containing unique port assignments
316-
func setupSequencerNode(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) {
316+
func setupSequencerNode(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) {
317317
t.Helper()
318318

319319
// Create passphrase file
@@ -357,7 +357,7 @@ func setupSequencerNode(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSe
357357
// setupSequencerNodeLazy initializes and starts the sequencer node in lazy mode.
358358
// In lazy mode, blocks are only produced when transactions are available,
359359
// not on a regular timer.
360-
func setupSequencerNodeLazy(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) {
360+
func setupSequencerNodeLazy(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) {
361361
t.Helper()
362362

363363
// Create passphrase file
@@ -417,7 +417,7 @@ func setupSequencerNodeLazy(t *testing.T, sut *SystemUnderTest, sequencerHome, j
417417
// - genesisHash: Hash of the genesis block for chain validation
418418
// - sequencerP2PAddress: P2P address of the sequencer node to connect to
419419
// - endpoints: TestEndpoints struct containing unique port assignments
420-
func setupFullNode(t *testing.T, sut *SystemUnderTest, fullNodeHome, sequencerHome, fullNodeJwtSecret, genesisHash, sequencerP2PAddress string, endpoints *TestEndpoints) {
420+
func setupFullNode(t testing.TB, sut *SystemUnderTest, fullNodeHome, sequencerHome, fullNodeJwtSecret, genesisHash, sequencerP2PAddress string, endpoints *TestEndpoints) {
421421
t.Helper()
422422

423423
// Initialize full node
@@ -478,7 +478,7 @@ var globalNonce uint64 = 0
478478
//
479479
// This is used in full node sync tests to verify that both nodes
480480
// include the same transaction in the same block number.
481-
func submitTransactionAndGetBlockNumber(t *testing.T, sequencerClients ...*ethclient.Client) (common.Hash, uint64) {
481+
func submitTransactionAndGetBlockNumber(t testing.TB, sequencerClients ...*ethclient.Client) (common.Hash, uint64) {
482482
t.Helper()
483483

484484
// Submit transaction to sequencer EVM with unique nonce
@@ -512,7 +512,7 @@ func submitTransactionAndGetBlockNumber(t *testing.T, sequencerClients ...*ethcl
512512
// - daPort: optional DA port to use (if empty, uses default)
513513
//
514514
// Returns: jwtSecret, fullNodeJwtSecret (empty if needsFullNode=false), genesisHash
515-
func setupCommonEVMTest(t *testing.T, sut *SystemUnderTest, needsFullNode bool, _ ...string) (string, string, string, *TestEndpoints) {
515+
func setupCommonEVMTest(t testing.TB, sut *SystemUnderTest, needsFullNode bool, _ ...string) (string, string, string, *TestEndpoints) {
516516
t.Helper()
517517

518518
// Reset global nonce for each test to ensure clean state
@@ -570,7 +570,7 @@ func setupCommonEVMTest(t *testing.T, sut *SystemUnderTest, needsFullNode bool,
570570
// - blockHeight: Height of the block to retrieve (use nil for latest)
571571
//
572572
// Returns: block hash, state root, transaction count, block number, and error
573-
func checkBlockInfoAt(t *testing.T, ethURL string, blockHeight *uint64) (common.Hash, common.Hash, int, uint64, error) {
573+
func checkBlockInfoAt(t testing.TB, ethURL string, blockHeight *uint64) (common.Hash, common.Hash, int, uint64, error) {
574574
t.Helper()
575575

576576
ctx := context.Background()
@@ -613,7 +613,7 @@ func checkBlockInfoAt(t *testing.T, ethURL string, blockHeight *uint64) (common.
613613
// - nodeHome: Directory path for sequencer node data
614614
//
615615
// Returns: genesisHash for the sequencer
616-
func setupSequencerOnlyTest(t *testing.T, sut *SystemUnderTest, nodeHome string) (string, string) {
616+
func setupSequencerOnlyTest(t testing.TB, sut *SystemUnderTest, nodeHome string) (string, string) {
617617
t.Helper()
618618

619619
// Use common setup (no full node needed)
@@ -635,7 +635,7 @@ func setupSequencerOnlyTest(t *testing.T, sut *SystemUnderTest, nodeHome string)
635635
// - sequencerHome: Directory path for sequencer node data
636636
// - jwtSecret: JWT secret for sequencer's EVM engine authentication
637637
// - genesisHash: Hash of the genesis block for chain validation
638-
func restartDAAndSequencer(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) {
638+
func restartDAAndSequencer(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) {
639639
t.Helper()
640640

641641
// First restart the local DA
@@ -685,7 +685,7 @@ func restartDAAndSequencer(t *testing.T, sut *SystemUnderTest, sequencerHome, jw
685685
// - sequencerHome: Directory path for sequencer node data
686686
// - jwtSecret: JWT secret for sequencer's EVM engine authentication
687687
// - genesisHash: Hash of the genesis block for chain validation
688-
func restartDAAndSequencerLazy(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) {
688+
func restartDAAndSequencerLazy(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string, endpoints *TestEndpoints) {
689689
t.Helper()
690690

691691
// First restart the local DA
@@ -736,7 +736,7 @@ func restartDAAndSequencerLazy(t *testing.T, sut *SystemUnderTest, sequencerHome
736736
// - sequencerHome: Directory path for sequencer node data
737737
// - jwtSecret: JWT secret for sequencer's EVM engine authentication
738738
// - genesisHash: Hash of the genesis block for chain validation
739-
func restartSequencerNode(t *testing.T, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string) {
739+
func restartSequencerNode(t testing.TB, sut *SystemUnderTest, sequencerHome, jwtSecret, genesisHash string) {
740740
t.Helper()
741741

742742
// Start sequencer node (without init - node already exists)
@@ -772,7 +772,7 @@ func restartSequencerNode(t *testing.T, sut *SystemUnderTest, sequencerHome, jwt
772772
// - nodeName: Human-readable name for logging (e.g., "sequencer", "full node")
773773
//
774774
// This function ensures that during lazy mode idle periods, no automatic block production occurs.
775-
func verifyNoBlockProduction(t *testing.T, client *ethclient.Client, duration time.Duration, nodeName string) {
775+
func verifyNoBlockProduction(t testing.TB, client *ethclient.Client, duration time.Duration, nodeName string) {
776776
t.Helper()
777777

778778
ctx := context.Background()

0 commit comments

Comments
 (0)