Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
81dc810
refactor: move spamoor benchmark into testify suite in test/e2e/bench…
chatton Feb 25, 2026
cc56590
merge main into cian/bench-refactor
chatton Feb 25, 2026
18fc15a
fix: correct BENCH_JSON_OUTPUT path for spamoor benchmark
chatton Feb 25, 2026
fccd9db
fix: place package pattern before test binary flags in benchmark CI
chatton Feb 25, 2026
ae525ca
fix: adjust evm-binary path for benchmark subpackage working directory
chatton Feb 25, 2026
039eaf7
wip: erc20 benchmark test
chatton Feb 25, 2026
85c9d2d
fix: exclude benchmark subpackage from make test-e2e
chatton Feb 25, 2026
fe8d166
fix: replace FilterLogs with header iteration and optimize spamoor co…
chatton Feb 26, 2026
99e3e42
fix: improve benchmark measurement window and reliability
chatton Feb 26, 2026
e4e06c5
fix: address PR review feedback for benchmark suite
chatton Mar 2, 2026
1c3b560
Merge branch 'main' into cian/bench-refactor
chatton Mar 2, 2026
03b9239
chore: specify http
chatton Mar 2, 2026
fe3ca23
chore: filter out benchmark tests from test-e2e
chatton Mar 2, 2026
8752fee
Merge branch 'main' into cian/bench-refactor
chatton Mar 2, 2026
06f532b
Merge branch 'cian/bench-refactor' into cian/erc20-benchmark
chatton Mar 2, 2026
560974a
Merge remote-tracking branch 'origin/main' into cian/erc20-benchmark
chatton Mar 2, 2026
054f2c6
refactor: centralize reth config and lower ERC20 spammer count
chatton Mar 2, 2026
26bb117
chore: collect all traces at once
chatton Mar 2, 2026
b88cae3
chore: self review
chatton Mar 2, 2026
272ab71
refactor: extract benchmark helpers to slim down ERC20 test body
chatton Mar 2, 2026
676e0d1
docs: add detailed documentation to benchmark helper methods
chatton Mar 2, 2026
f4949a1
ci: add ERC20 throughput benchmark job
chatton Mar 2, 2026
4c7b7e1
chore: remove span assertions
chatton Mar 2, 2026
6f56c80
chore: adding gas burner test
chatton Mar 2, 2026
3eb733a
chore: merge main
chatton Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
wip: erc20 benchmark test
  • Loading branch information
chatton committed Feb 25, 2026
commit 039eaf75c5401296236b3f56683c4e59d46ca040
108 changes: 108 additions & 0 deletions test/e2e/benchmark/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//go:build evm

package benchmark

import (
"context"
"fmt"
"math/big"
"time"

"github.com/celestiaorg/tastora/framework/docker/evstack/spamoor"
"github.com/ethereum/go-ethereum/ethclient"
)

// blockMetrics holds aggregated gas and transaction data across a range of blocks.
type blockMetrics struct {
StartBlock uint64
EndBlock uint64
BlockCount int
TotalGasUsed uint64
TotalTxCount int
GasPerBlock []uint64
TxPerBlock []int
}

// avgGasPerBlock returns the mean gas used per block.
func (m *blockMetrics) avgGasPerBlock() float64 {
if m.BlockCount == 0 {
return 0
}
return float64(m.TotalGasUsed) / float64(m.BlockCount)
}

// avgTxPerBlock returns the mean transaction count per block.
func (m *blockMetrics) avgTxPerBlock() float64 {
if m.BlockCount == 0 {
return 0
}
return float64(m.TotalTxCount) / float64(m.BlockCount)
}

// mgasPerSec calculates the achieved throughput in megagas per second.
func mgasPerSec(totalGasUsed uint64, elapsed time.Duration) float64 {
if elapsed <= 0 {
return 0
}
return float64(totalGasUsed) / elapsed.Seconds() / 1e6
}

// waitForSpamoorDone polls spamoor metrics until the total sent count reaches
// the target or the context is cancelled. It returns the final sent and failed
// counts.
func waitForSpamoorDone(ctx context.Context, api *spamoor.API, targetCount int, pollInterval time.Duration) (sent, failed float64, err error) {
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return sent, failed, fmt.Errorf("timed out waiting for spamoor to send %d txs (sent %.0f): %w", targetCount, sent, ctx.Err())
case <-ticker.C:
metrics, mErr := api.GetMetrics()
if mErr != nil {
continue
}
sent = sumCounter(metrics["spamoor_transactions_sent_total"])
failed = sumCounter(metrics["spamoor_transactions_failed_total"])
if sent >= float64(targetCount) {
return sent, failed, nil
}
}
}
}

// collectBlockMetrics fetches block headers from startBlock to endBlock
// (inclusive) and aggregates gas and transaction counts. Blocks with zero
// transactions are skipped to avoid skewing averages with empty blocks.
func collectBlockMetrics(ctx context.Context, client *ethclient.Client, startBlock, endBlock uint64) (*blockMetrics, error) {
if endBlock < startBlock {
return nil, fmt.Errorf("endBlock %d < startBlock %d", endBlock, startBlock)
}

m := &blockMetrics{
StartBlock: startBlock,
EndBlock: endBlock,
}

for n := startBlock; n <= endBlock; n++ {
block, err := client.BlockByNumber(ctx, new(big.Int).SetUint64(n))
if err != nil {
return nil, fmt.Errorf("failed to fetch block %d: %w", n, err)
}

txCount := len(block.Transactions())
if txCount == 0 {
continue
}

gasUsed := block.GasUsed()
m.BlockCount++
m.TotalGasUsed += gasUsed
m.TotalTxCount += txCount
m.GasPerBlock = append(m.GasPerBlock, gasUsed)
m.TxPerBlock = append(m.TxPerBlock, txCount)
}

return m, nil
}
152 changes: 152 additions & 0 deletions test/e2e/benchmark/spamoor_erc20_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//go:build evm

package benchmark

import (
"context"
"time"

"github.com/celestiaorg/tastora/framework/docker/evstack/spamoor"
e2e "github.com/evstack/ev-node/test/e2e"
)

// TestERC20Throughput measures ERC-20 token transfer throughput in isolation.
// Each tx is ~65K gas (contract call with storage reads/writes)
//
// Primary metric: achieved MGas/s.
// Diagnostic metrics: per-span latency breakdown, gas/block, tx/block.
func (s *SpamoorSuite) TestERC20Throughput() {
const (
totalCount = 3000
serviceName = "ev-node-erc20"
waitTimeout = 3 * time.Minute
)

t := s.T()
ctx := t.Context()
w := newResultWriter(t, "ERC20Throughput")
defer w.flush()

e := s.setupEnv(config{
rethTag: "pr-140",
serviceName: serviceName,
})

erc20Config := map[string]any{
"throughput": 100,
"total_count": totalCount,
"max_pending": 4000,
"max_wallets": 300,
"base_fee": 20,
"tip_fee": 3,
"refill_amount": "5000000000000000000",
"refill_balance": "2000000000000000000",
"refill_interval": 600,
}

// record the starting block before generating load
startHeader, err := e.ethClient.HeaderByNumber(ctx, nil)
s.Require().NoError(err, "failed to get start block header")
startBlock := startHeader.Number.Uint64()
loadStart := time.Now()

id, err := e.spamoorAPI.CreateSpammer("bench-erc20", spamoor.ScenarioERC20TX, erc20Config, true)
s.Require().NoError(err, "failed to create erc20 spammer")
t.Cleanup(func() { _ = e.spamoorAPI.DeleteSpammer(id) })

// wait for spamoor to finish sending all transactions
waitCtx, cancel := context.WithTimeout(ctx, waitTimeout)
defer cancel()
sent, failed, err := waitForSpamoorDone(waitCtx, e.spamoorAPI, totalCount, 2*time.Second)
s.Require().NoError(err, "spamoor did not finish in time")

// allow a short settle period for remaining txs to be included in blocks
time.Sleep(5 * time.Second)
wallClock := time.Since(loadStart)

endHeader, err := e.ethClient.HeaderByNumber(ctx, nil)
s.Require().NoError(err, "failed to get end block header")
endBlock := endHeader.Number.Uint64()

// collect block-level gas/tx metrics
bm, err := collectBlockMetrics(ctx, e.ethClient, startBlock, endBlock)
s.Require().NoError(err, "failed to collect block metrics")

achievedMGas := mgasPerSec(bm.TotalGasUsed, wallClock)
achievedTPS := float64(bm.TotalTxCount) / wallClock.Seconds()

t.Logf("ERC20 throughput: %.2f MGas/s, %.1f TPS over %s (%d blocks, %d txs, %.2f avg gas/block)",
achievedMGas, achievedTPS, wallClock.Round(time.Millisecond),
bm.BlockCount, bm.TotalTxCount, bm.avgGasPerBlock())

// collect and report traces
evNodeSpans := s.collectServiceTraces(e, serviceName)
evRethSpans := s.collectServiceTraces(e, "ev-reth")
e2e.PrintTraceReport(t, serviceName, evNodeSpans)
e2e.PrintTraceReport(t, "ev-reth", evRethSpans)

// compute ev-node overhead ratio
spanStats := e2e.AggregateSpanStats(evNodeSpans)
if produceBlock, ok := spanStats["BlockExecutor.ProduceBlock"]; ok {
if executeTxs, ok2 := spanStats["Executor.ExecuteTxs"]; ok2 {
produceAvg := float64(produceBlock.Total.Microseconds()) / float64(produceBlock.Count)
executeAvg := float64(executeTxs.Total.Microseconds()) / float64(executeTxs.Count)
if produceAvg > 0 {
overhead := (produceAvg - executeAvg) / produceAvg * 100
t.Logf("ev-node overhead: %.1f%%", overhead)
w.addEntry(entry{
Name: "ERC20Throughput - ev-node overhead",
Unit: "%",
Value: overhead,
})
}
}
}

// write all metrics to benchmark output
w.addEntry(entry{
Name: "ERC20Throughput - MGas/s",
Unit: "MGas/s",
Value: achievedMGas,
})
w.addEntry(entry{
Name: "ERC20Throughput - TPS",
Unit: "tx/s",
Value: achievedTPS,
})
w.addEntry(entry{
Name: "ERC20Throughput - avg gas/block",
Unit: "gas",
Value: bm.avgGasPerBlock(),
})
w.addEntry(entry{
Name: "ERC20Throughput - avg tx/block",
Unit: "count",
Value: bm.avgTxPerBlock(),
})
w.addEntry(entry{
Name: "ERC20Throughput - blocks/s",
Unit: "blocks/s",
Value: float64(bm.BlockCount) / wallClock.Seconds(),
})

// add per-span avg latencies
w.addSpans(append(evNodeSpans, evRethSpans...))

// assertions
s.Require().Greater(sent, float64(0), "at least one transaction should have been sent")
s.Require().Zero(failed, "no transactions should have failed")

// assert expected ev-node spans are present
assertSpanNames(t, evNodeSpans, []string{
"BlockExecutor.ProduceBlock",
"BlockExecutor.ApplyBlock",
"Executor.ExecuteTxs",
"Executor.SetFinal",
"Engine.ForkchoiceUpdated",
"Engine.NewPayload",
"Engine.GetPayload",
"Sequencer.GetNextBatch",
"DA.Submit",
}, serviceName)
}