1
0
Fork 0
forked from forks/go-ethereum

cmd/workload: RPC workload tests for filters and history (#31189)

Co-authored-by: Felix Lange <fjl@twurst.com>
Co-authored-by: Sina Mahmoodi <itz.s1na@gmail.com>
This commit is contained in:
Felföldi Zsolt 2025-02-27 00:07:14 +01:00 committed by GitHub
parent 2585776aab
commit 939a804146
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1504 additions and 48 deletions

View file

@ -93,7 +93,7 @@ func init() {
}
func abigen(c *cli.Context) error {
utils.CheckExclusive(c, abiFlag, jsonFlag) // Only one source can be selected.
flags.CheckExclusive(c, abiFlag, jsonFlag) // Only one source can be selected.
if c.String(pkgFlag.Name) == "" {
utils.Fatalf("No destination package specified (--pkg)")

View file

@ -1197,7 +1197,7 @@ func setWS(ctx *cli.Context, cfg *node.Config) {
// setIPC creates an IPC path configuration from the set command line flags,
// returning an empty string if IPC was explicitly disabled, or the set path.
func setIPC(ctx *cli.Context, cfg *node.Config) {
CheckExclusive(ctx, IPCDisabledFlag, IPCPathFlag)
flags.CheckExclusive(ctx, IPCDisabledFlag, IPCPathFlag)
switch {
case ctx.Bool(IPCDisabledFlag.Name):
cfg.IPCPath = ""
@ -1295,8 +1295,8 @@ func SetP2PConfig(ctx *cli.Context, cfg *p2p.Config) {
cfg.NoDiscovery = true
}
CheckExclusive(ctx, DiscoveryV4Flag, NoDiscoverFlag)
CheckExclusive(ctx, DiscoveryV5Flag, NoDiscoverFlag)
flags.CheckExclusive(ctx, DiscoveryV4Flag, NoDiscoverFlag)
flags.CheckExclusive(ctx, DiscoveryV5Flag, NoDiscoverFlag)
cfg.DiscoveryV4 = ctx.Bool(DiscoveryV4Flag.Name)
cfg.DiscoveryV5 = ctx.Bool(DiscoveryV5Flag.Name)
@ -1528,52 +1528,11 @@ func setRequiredBlocks(ctx *cli.Context, cfg *ethconfig.Config) {
}
}
// CheckExclusive verifies that only a single instance of the provided flags was
// set by the user. Each flag might optionally be followed by a string type to
// specialize it further.
func CheckExclusive(ctx *cli.Context, args ...interface{}) {
set := make([]string, 0, 1)
for i := 0; i < len(args); i++ {
// Make sure the next argument is a flag and skip if not set
flag, ok := args[i].(cli.Flag)
if !ok {
panic(fmt.Sprintf("invalid argument, not cli.Flag type: %T", args[i]))
}
// Check if next arg extends current and expand its name if so
name := flag.Names()[0]
if i+1 < len(args) {
switch option := args[i+1].(type) {
case string:
// Extended flag check, make sure value set doesn't conflict with passed in option
if ctx.String(flag.Names()[0]) == option {
name += "=" + option
set = append(set, "--"+name)
}
// shift arguments and continue
i++
continue
case cli.Flag:
default:
panic(fmt.Sprintf("invalid argument, not cli.Flag or string extension: %T", args[i+1]))
}
}
// Mark the flag if it's set
if ctx.IsSet(flag.Names()[0]) {
set = append(set, "--"+name)
}
}
if len(set) > 1 {
Fatalf("Flags %v can't be used at the same time", strings.Join(set, ", "))
}
}
// SetEthConfig applies eth-related command line flags to the config.
func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) {
// Avoid conflicting network flags
CheckExclusive(ctx, MainnetFlag, DeveloperFlag, SepoliaFlag, HoleskyFlag)
CheckExclusive(ctx, DeveloperFlag, ExternalSignerFlag) // Can't use both ephemeral unlocked and external signer
flags.CheckExclusive(ctx, MainnetFlag, DeveloperFlag, SepoliaFlag, HoleskyFlag)
flags.CheckExclusive(ctx, DeveloperFlag, ExternalSignerFlag) // Can't use both ephemeral unlocked and external signer
// Set configurations from CLI flags
setEtherbase(ctx, cfg)
@ -1841,7 +1800,7 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) {
func MakeBeaconLightConfig(ctx *cli.Context) bparams.ClientConfig {
var config bparams.ClientConfig
customConfig := ctx.IsSet(BeaconConfigFlag.Name)
CheckExclusive(ctx, MainnetFlag, SepoliaFlag, HoleskyFlag, BeaconConfigFlag)
flags.CheckExclusive(ctx, MainnetFlag, SepoliaFlag, HoleskyFlag, BeaconConfigFlag)
switch {
case ctx.Bool(MainnetFlag.Name):
config.ChainConfig = *bparams.MainnetLightConfig

29
cmd/workload/README.md Normal file
View file

@ -0,0 +1,29 @@
## Workload Testing Tool
This tool performs RPC calls against a live node. It has tests for the Sepolia testnet and
Mainnet. Note the tests require a fully synced node.
To run the tests against a Sepolia node, use:
```shell
> ./workload test --sepolia http://host:8545
```
To run a specific test, use the `--run` flag to filter the test cases. Filtering works
similar to the `go test` command. For example, to run only tests for `eth_getBlockByHash`
and `eth_getBlockByNumber`, use this command:
```
> ./workload test --sepolia --run History/getBlockBy http://host:8545
```
### Regenerating tests
There is a facility for updating the tests from the chain. This can also be used to
generate the tests for a new network. As an example, to recreate tests for mainnet, run
the following commands (in this directory) against a synced mainnet node:
```shell
> go run . filtergen --queries queries/filter_queries_mainnet.json http://host:8545
> go run . historygen --history-tests queries/history_mainnet.json http://host:8545
```

216
cmd/workload/filtertest.go Normal file
View file

@ -0,0 +1,216 @@
// Copyright 2025 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"context"
"encoding/json"
"fmt"
"math/big"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/internal/utesting"
"github.com/ethereum/go-ethereum/rlp"
"github.com/ethereum/go-ethereum/rpc"
)
type filterTestSuite struct {
cfg testConfig
queries [][]*filterQuery
}
func newFilterTestSuite(cfg testConfig) *filterTestSuite {
s := &filterTestSuite{cfg: cfg}
if err := s.loadQueries(); err != nil {
exit(err)
}
return s
}
func (s *filterTestSuite) allTests() []utesting.Test {
return []utesting.Test{
{Name: "Filter/ShortRange", Fn: s.filterShortRange},
{Name: "Filter/LongRange", Fn: s.filterLongRange, Slow: true},
{Name: "Filter/FullRange", Fn: s.filterFullRange, Slow: true},
}
}
func (s *filterTestSuite) filterRange(t *utesting.T, test func(query *filterQuery) bool, do func(t *utesting.T, query *filterQuery)) {
var count, total int
for _, bucket := range s.queries {
for _, query := range bucket {
if test(query) {
total++
}
}
}
if total == 0 {
t.Fatalf("No suitable queries available")
}
start := time.Now()
last := start
for _, bucket := range s.queries {
for _, query := range bucket {
if test(query) {
do(t, query)
count++
if time.Since(last) > time.Second*5 {
t.Logf("Making filter query %d/%d (elapsed: %v)", count, total, time.Since(start))
last = time.Now()
}
}
}
}
t.Logf("Made %d filter queries (elapsed: %v)", count, time.Since(start))
}
const filterRangeThreshold = 10000
// filterShortRange runs all short-range filter tests.
func (s *filterTestSuite) filterShortRange(t *utesting.T) {
s.filterRange(t, func(query *filterQuery) bool {
return query.ToBlock+1-query.FromBlock <= filterRangeThreshold
}, s.queryAndCheck)
}
// filterShortRange runs all long-range filter tests.
func (s *filterTestSuite) filterLongRange(t *utesting.T) {
s.filterRange(t, func(query *filterQuery) bool {
return query.ToBlock+1-query.FromBlock > filterRangeThreshold
}, s.queryAndCheck)
}
// filterFullRange runs all filter tests, extending their range from genesis up
// to the latest block. Note that results are only partially verified in this mode.
func (s *filterTestSuite) filterFullRange(t *utesting.T) {
finalized := mustGetFinalizedBlock(s.cfg.client)
s.filterRange(t, func(query *filterQuery) bool {
return query.ToBlock+1-query.FromBlock > finalized/2
}, s.fullRangeQueryAndCheck)
}
func (s *filterTestSuite) queryAndCheck(t *utesting.T, query *filterQuery) {
query.run(s.cfg.client)
if query.Err != nil {
t.Errorf("Filter query failed (fromBlock: %d toBlock: %d addresses: %v topics: %v error: %v)", query.FromBlock, query.ToBlock, query.Address, query.Topics, query.Err)
return
}
if *query.ResultHash != query.calculateHash() {
t.Fatalf("Filter query result mismatch (fromBlock: %d toBlock: %d addresses: %v topics: %v)", query.FromBlock, query.ToBlock, query.Address, query.Topics)
}
}
func (s *filterTestSuite) fullRangeQueryAndCheck(t *utesting.T, query *filterQuery) {
frQuery := &filterQuery{ // create full range query
FromBlock: 0,
ToBlock: int64(rpc.LatestBlockNumber),
Address: query.Address,
Topics: query.Topics,
}
frQuery.run(s.cfg.client)
if frQuery.Err != nil {
t.Errorf("Full range filter query failed (addresses: %v topics: %v error: %v)", frQuery.Address, frQuery.Topics, frQuery.Err)
return
}
// filter out results outside the original query range
j := 0
for _, log := range frQuery.results {
if int64(log.BlockNumber) >= query.FromBlock && int64(log.BlockNumber) <= query.ToBlock {
frQuery.results[j] = log
j++
}
}
frQuery.results = frQuery.results[:j]
if *query.ResultHash != frQuery.calculateHash() {
t.Fatalf("Full range filter query result mismatch (fromBlock: %d toBlock: %d addresses: %v topics: %v)", query.FromBlock, query.ToBlock, query.Address, query.Topics)
}
}
func (s *filterTestSuite) loadQueries() error {
file, err := s.cfg.fsys.Open(s.cfg.filterQueryFile)
if err != nil {
return fmt.Errorf("can't open filterQueryFile: %v", err)
}
defer file.Close()
var queries [][]*filterQuery
if err := json.NewDecoder(file).Decode(&queries); err != nil {
return fmt.Errorf("invalid JSON in %s: %v", s.cfg.filterQueryFile, err)
}
var count int
for _, bucket := range queries {
count += len(bucket)
}
if count == 0 {
return fmt.Errorf("filterQueryFile %s is empty", s.cfg.filterQueryFile)
}
s.queries = queries
return nil
}
// filterQuery is a single query for testing.
type filterQuery struct {
FromBlock int64 `json:"fromBlock"`
ToBlock int64 `json:"toBlock"`
Address []common.Address `json:"address"`
Topics [][]common.Hash `json:"topics"`
ResultHash *common.Hash `json:"resultHash,omitempty"`
results []types.Log
Err error `json:"error,omitempty"`
}
func (fq *filterQuery) isWildcard() bool {
if len(fq.Address) != 0 {
return false
}
for _, topics := range fq.Topics {
if len(topics) != 0 {
return false
}
}
return true
}
func (fq *filterQuery) calculateHash() common.Hash {
enc, err := rlp.EncodeToBytes(&fq.results)
if err != nil {
exit(fmt.Errorf("Error encoding logs: %v", err))
}
return crypto.Keccak256Hash(enc)
}
func (fq *filterQuery) run(client *client) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
logs, err := client.Eth.FilterLogs(ctx, ethereum.FilterQuery{
FromBlock: big.NewInt(fq.FromBlock),
ToBlock: big.NewInt(fq.ToBlock),
Addresses: fq.Address,
Topics: fq.Topics,
})
if err != nil {
fq.Err = err
fmt.Printf("Filter query failed: fromBlock: %d toBlock: %d addresses: %v topics: %v error: %v\n",
fq.FromBlock, fq.ToBlock, fq.Address, fq.Topics, err)
return
}
fq.results = logs
}

View file

@ -0,0 +1,382 @@
// Copyright 2025 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"context"
"encoding/json"
"fmt"
"math"
"math/big"
"math/rand"
"os"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/internal/flags"
"github.com/ethereum/go-ethereum/rpc"
"github.com/urfave/cli/v2"
)
var (
filterGenerateCommand = &cli.Command{
Name: "filtergen",
Usage: "Generates query set for log filter workload test",
ArgsUsage: "<RPC endpoint URL>",
Action: filterGenCmd,
Flags: []cli.Flag{
filterQueryFileFlag,
filterErrorFileFlag,
},
}
filterQueryFileFlag = &cli.StringFlag{
Name: "queries",
Usage: "JSON file containing filter test queries",
Value: "filter_queries.json",
Category: flags.TestingCategory,
}
filterErrorFileFlag = &cli.StringFlag{
Name: "errors",
Usage: "JSON file containing failed filter queries",
Value: "filter_errors.json",
Category: flags.TestingCategory,
}
)
// filterGenCmd is the main function of the filter tests generator.
func filterGenCmd(ctx *cli.Context) error {
f := newFilterTestGen(ctx)
lastWrite := time.Now()
for {
select {
case <-ctx.Done():
return nil
default:
}
f.updateFinalizedBlock()
query := f.newQuery()
query.run(f.client)
if query.Err != nil {
f.errors = append(f.errors, query)
continue
}
if len(query.results) > 0 && len(query.results) <= maxFilterResultSize {
for {
extQuery := f.extendRange(query)
if extQuery == nil {
break
}
extQuery.run(f.client)
if extQuery.Err == nil && len(extQuery.results) < len(query.results) {
extQuery.Err = fmt.Errorf("invalid result length; old range %d %d; old length %d; new range %d %d; new length %d; address %v; Topics %v",
query.FromBlock, query.ToBlock, len(query.results),
extQuery.FromBlock, extQuery.ToBlock, len(extQuery.results),
extQuery.Address, extQuery.Topics,
)
}
if extQuery.Err != nil {
f.errors = append(f.errors, extQuery)
break
}
if len(extQuery.results) > maxFilterResultSize {
break
}
query = extQuery
}
f.storeQuery(query)
if time.Since(lastWrite) > time.Second*10 {
f.writeQueries()
f.writeErrors()
lastWrite = time.Now()
}
}
}
}
// filterTestGen is the filter query test generator.
type filterTestGen struct {
client *client
queryFile string
errorFile string
finalizedBlock int64
queries [filterBuckets][]*filterQuery
errors []*filterQuery
}
func newFilterTestGen(ctx *cli.Context) *filterTestGen {
return &filterTestGen{
client: makeClient(ctx),
queryFile: ctx.String(filterQueryFileFlag.Name),
errorFile: ctx.String(filterErrorFileFlag.Name),
}
}
func (s *filterTestGen) updateFinalizedBlock() {
s.finalizedBlock = mustGetFinalizedBlock(s.client)
}
const (
// Parameter of the random filter query generator.
maxFilterRange = 10000000
maxFilterResultSize = 300
filterBuckets = 10
maxFilterBucketSize = 100
filterSeedChance = 10
filterMergeChance = 45
)
// storeQuery adds a filter query to the output file.
func (s *filterTestGen) storeQuery(query *filterQuery) {
query.ResultHash = new(common.Hash)
*query.ResultHash = query.calculateHash()
logRatio := math.Log(float64(len(query.results))*float64(s.finalizedBlock)/float64(query.ToBlock+1-query.FromBlock)) / math.Log(float64(s.finalizedBlock)*maxFilterResultSize)
bucket := int(math.Floor(logRatio * filterBuckets))
if bucket >= filterBuckets {
bucket = filterBuckets - 1
}
if len(s.queries[bucket]) < maxFilterBucketSize {
s.queries[bucket] = append(s.queries[bucket], query)
} else {
s.queries[bucket][rand.Intn(len(s.queries[bucket]))] = query
}
fmt.Print("Generated queries per bucket:")
for _, list := range s.queries {
fmt.Print(" ", len(list))
}
fmt.Println()
}
func (s *filterTestGen) extendRange(q *filterQuery) *filterQuery {
rangeLen := q.ToBlock + 1 - q.FromBlock
extLen := rand.Int63n(rangeLen) + 1
if rangeLen+extLen > s.finalizedBlock {
return nil
}
extBefore := min(rand.Int63n(extLen+1), q.FromBlock)
extAfter := extLen - extBefore
if q.ToBlock+extAfter > s.finalizedBlock {
d := q.ToBlock + extAfter - s.finalizedBlock
extAfter -= d
if extBefore+d <= q.FromBlock {
extBefore += d
} else {
extBefore = q.FromBlock
}
}
return &filterQuery{
FromBlock: q.FromBlock - extBefore,
ToBlock: q.ToBlock + extAfter,
Address: q.Address,
Topics: q.Topics,
}
}
// newQuery generates a new filter query.
func (s *filterTestGen) newQuery() *filterQuery {
for {
t := rand.Intn(100)
if t < filterSeedChance {
return s.newSeedQuery()
}
if t < filterSeedChance+filterMergeChance {
if query := s.newMergedQuery(); query != nil {
return query
}
continue
}
if query := s.newNarrowedQuery(); query != nil {
return query
}
}
}
// newSeedQuery creates a query that gets all logs in a random non-finalized block.
func (s *filterTestGen) newSeedQuery() *filterQuery {
block := rand.Int63n(s.finalizedBlock + 1)
return &filterQuery{
FromBlock: block,
ToBlock: block,
}
}
// newMergedQuery creates a new query by combining (with OR) the filter criteria
// of two existing queries (chosen at random).
func (s *filterTestGen) newMergedQuery() *filterQuery {
q1 := s.randomQuery()
q2 := s.randomQuery()
if q1 == nil || q2 == nil || q1 == q2 {
return nil
}
var (
block int64
topicCount int
)
if rand.Intn(2) == 0 {
block = q1.FromBlock + rand.Int63n(q1.ToBlock+1-q1.FromBlock)
topicCount = len(q1.Topics)
} else {
block = q2.FromBlock + rand.Int63n(q2.ToBlock+1-q2.FromBlock)
topicCount = len(q2.Topics)
}
m := &filterQuery{
FromBlock: block,
ToBlock: block,
Topics: make([][]common.Hash, topicCount),
}
for _, addr := range q1.Address {
if rand.Intn(2) == 0 {
m.Address = append(m.Address, addr)
}
}
for _, addr := range q2.Address {
if rand.Intn(2) == 0 {
m.Address = append(m.Address, addr)
}
}
for i := range m.Topics {
if len(q1.Topics) > i {
for _, topic := range q1.Topics[i] {
if rand.Intn(2) == 0 {
m.Topics[i] = append(m.Topics[i], topic)
}
}
}
if len(q2.Topics) > i {
for _, topic := range q2.Topics[i] {
if rand.Intn(2) == 0 {
m.Topics[i] = append(m.Topics[i], topic)
}
}
}
}
return m
}
// newNarrowedQuery creates a new query by 'narrowing' an existing (randomly chosen)
// query. The new query is made more specific by analyzing the filter criteria and adding
// topics/addresses from the known result set.
func (s *filterTestGen) newNarrowedQuery() *filterQuery {
q := s.randomQuery()
if q == nil {
return nil
}
log := q.results[rand.Intn(len(q.results))]
var emptyCount int
if len(q.Address) == 0 {
emptyCount++
}
for i := range log.Topics {
if len(q.Topics) <= i || len(q.Topics[i]) == 0 {
emptyCount++
}
}
if emptyCount == 0 {
return nil
}
query := &filterQuery{
FromBlock: q.FromBlock,
ToBlock: q.ToBlock,
Address: make([]common.Address, len(q.Address)),
Topics: make([][]common.Hash, len(q.Topics)),
}
copy(query.Address, q.Address)
for i, topics := range q.Topics {
if len(topics) > 0 {
query.Topics[i] = make([]common.Hash, len(topics))
copy(query.Topics[i], topics)
}
}
pick := rand.Intn(emptyCount)
if len(query.Address) == 0 {
if pick == 0 {
query.Address = []common.Address{log.Address}
return query
}
pick--
}
for i := range log.Topics {
if len(query.Topics) <= i || len(query.Topics[i]) == 0 {
if pick == 0 {
if len(query.Topics) <= i {
query.Topics = append(query.Topics, make([][]common.Hash, i+1-len(query.Topics))...)
}
query.Topics[i] = []common.Hash{log.Topics[i]}
return query
}
pick--
}
}
panic("unreachable")
}
// randomQuery returns a random query from the ones that were already generated.
func (s *filterTestGen) randomQuery() *filterQuery {
var bucket, bucketCount int
for _, list := range s.queries {
if len(list) > 0 {
bucketCount++
}
}
if bucketCount == 0 {
return nil
}
pick := rand.Intn(bucketCount)
for i, list := range s.queries {
if len(list) > 0 {
if pick == 0 {
bucket = i
break
}
pick--
}
}
return s.queries[bucket][rand.Intn(len(s.queries[bucket]))]
}
// writeQueries serializes the generated queries to the output file.
func (s *filterTestGen) writeQueries() {
file, err := os.Create(s.queryFile)
if err != nil {
exit(fmt.Errorf("Error creating filter test query file %s: %v", s.queryFile, err))
return
}
json.NewEncoder(file).Encode(&s.queries)
file.Close()
}
// writeQueries serializes the generated errors to the error file.
func (s *filterTestGen) writeErrors() {
file, err := os.Create(s.errorFile)
if err != nil {
exit(fmt.Errorf("Error creating filter error file %s: %v", s.errorFile, err))
return
}
defer file.Close()
json.NewEncoder(file).Encode(s.errors)
}
func mustGetFinalizedBlock(client *client) int64 {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
header, err := client.Eth.HeaderByNumber(ctx, big.NewInt(int64(rpc.FinalizedBlockNumber)))
if err != nil {
exit(fmt.Errorf("could not fetch finalized header (error: %v)", err))
}
return header.Number.Int64()
}

View file

@ -0,0 +1,137 @@
// Copyright 2025 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"fmt"
"math/rand"
"slices"
"sort"
"time"
"github.com/urfave/cli/v2"
)
var (
filterPerfCommand = &cli.Command{
Name: "filterperf",
Usage: "Runs log filter performance test against an RPC endpoint",
ArgsUsage: "<RPC endpoint URL>",
Action: filterPerfCmd,
Flags: []cli.Flag{
testSepoliaFlag,
testMainnetFlag,
filterQueryFileFlag,
filterErrorFileFlag,
},
}
)
const passCount = 1
func filterPerfCmd(ctx *cli.Context) error {
cfg := testConfigFromCLI(ctx)
f := newFilterTestSuite(cfg)
type queryTest struct {
query *filterQuery
bucket, index int
runtime []time.Duration
medianTime time.Duration
}
var queries, processed []queryTest
for i, bucket := range f.queries[:] {
for j, query := range bucket {
queries = append(queries, queryTest{query: query, bucket: i, index: j})
}
}
// Run test queries.
var failed, mismatch int
for i := 1; i <= passCount; i++ {
fmt.Println("Performance test pass", i, "/", passCount)
for len(queries) > 0 {
pick := rand.Intn(len(queries))
qt := queries[pick]
queries[pick] = queries[len(queries)-1]
queries = queries[:len(queries)-1]
start := time.Now()
qt.query.run(cfg.client)
qt.runtime = append(qt.runtime, time.Since(start))
slices.Sort(qt.runtime)
qt.medianTime = qt.runtime[len(qt.runtime)/2]
if qt.query.Err != nil {
failed++
continue
}
if rhash := qt.query.calculateHash(); *qt.query.ResultHash != rhash {
fmt.Printf("Filter query result mismatch: fromBlock: %d toBlock: %d addresses: %v topics: %v expected hash: %064x calculated hash: %064x\n", qt.query.FromBlock, qt.query.ToBlock, qt.query.Address, qt.query.Topics, *qt.query.ResultHash, rhash)
continue
}
processed = append(processed, qt)
if len(processed)%50 == 0 {
fmt.Println(" processed:", len(processed), "remaining", len(queries), "failed:", failed, "result mismatch:", mismatch)
}
}
queries, processed = processed, nil
}
// Show results and stats.
fmt.Println("Performance test finished; processed:", len(queries), "failed:", failed, "result mismatch:", mismatch)
stats := make([]bucketStats, len(f.queries))
var wildcardStats bucketStats
for _, qt := range queries {
bs := &stats[qt.bucket]
if qt.query.isWildcard() {
bs = &wildcardStats
}
bs.blocks += qt.query.ToBlock + 1 - qt.query.FromBlock
bs.count++
bs.logs += len(qt.query.results)
bs.runtime += qt.medianTime
}
fmt.Println()
for i := range stats {
stats[i].print(fmt.Sprintf("bucket #%d", i+1))
}
wildcardStats.print("wild card queries")
fmt.Println()
sort.Slice(queries, func(i, j int) bool {
return queries[i].medianTime > queries[j].medianTime
})
for i := 0; i < 10; i++ {
q := queries[i]
fmt.Printf("Most expensive query #%-2d median runtime: %13v max runtime: %13v result count: %4d fromBlock: %9d toBlock: %9d addresses: %v topics: %v\n",
i+1, q.medianTime, q.runtime[len(q.runtime)-1], len(q.query.results), q.query.FromBlock, q.query.ToBlock, q.query.Address, q.query.Topics)
}
return nil
}
type bucketStats struct {
blocks int64
count, logs int
runtime time.Duration
}
func (st *bucketStats) print(name string) {
if st.count == 0 {
return
}
fmt.Printf("%-20s queries: %4d average block length: %12.2f average log count: %7.2f average runtime: %13v\n",
name, st.count, float64(st.blocks)/float64(st.count), float64(st.logs)/float64(st.count), st.runtime/time.Duration(st.count))
}

309
cmd/workload/historytest.go Normal file
View file

@ -0,0 +1,309 @@
// Copyright 2025 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"context"
"encoding/json"
"fmt"
"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/internal/utesting"
)
// historyTest is the content of a history test.
type historyTest struct {
BlockNumbers []uint64 `json:"blockNumbers"`
BlockHashes []common.Hash `json:"blockHashes"`
TxCounts []int `json:"txCounts"`
TxHashIndex []int `json:"txHashIndex"`
TxHashes []*common.Hash `json:"txHashes"`
ReceiptsHashes []common.Hash `json:"blockReceiptsHashes"`
}
type historyTestSuite struct {
cfg testConfig
tests historyTest
}
func newHistoryTestSuite(cfg testConfig) *historyTestSuite {
s := &historyTestSuite{cfg: cfg}
if err := s.loadTests(); err != nil {
exit(err)
}
return s
}
func (s *historyTestSuite) loadTests() error {
file, err := s.cfg.fsys.Open(s.cfg.historyTestFile)
if err != nil {
return fmt.Errorf("can't open historyTestFile: %v", err)
}
defer file.Close()
if err := json.NewDecoder(file).Decode(&s.tests); err != nil {
return fmt.Errorf("invalid JSON in %s: %v", s.cfg.historyTestFile, err)
}
if len(s.tests.BlockNumbers) == 0 {
return fmt.Errorf("historyTestFile %s has no test data", s.cfg.historyTestFile)
}
return nil
}
func (s *historyTestSuite) allTests() []utesting.Test {
return []utesting.Test{
{
Name: "History/getBlockByHash",
Fn: s.testGetBlockByHash,
},
{
Name: "History/getBlockByNumber",
Fn: s.testGetBlockByNumber,
},
{
Name: "History/getBlockReceiptsByHash",
Fn: s.testGetBlockReceiptsByHash,
},
{
Name: "History/getBlockReceiptsByNumber",
Fn: s.testGetBlockReceiptsByNumber,
},
{
Name: "History/getBlockTransactionCountByHash",
Fn: s.testGetBlockTransactionCountByHash,
},
{
Name: "History/getBlockTransactionCountByNumber",
Fn: s.testGetBlockTransactionCountByNumber,
},
{
Name: "History/getTransactionByBlockHashAndIndex",
Fn: s.testGetTransactionByBlockHashAndIndex,
},
{
Name: "History/getTransactionByBlockNumberAndIndex",
Fn: s.testGetTransactionByBlockNumberAndIndex,
},
}
}
func (s *historyTestSuite) testGetBlockByHash(t *utesting.T) {
ctx := context.Background()
for i, num := range s.tests.BlockNumbers {
bhash := s.tests.BlockHashes[i]
b, err := s.cfg.client.getBlockByHash(ctx, bhash, false)
if err != nil {
t.Fatalf("block %d (hash %v): error %v", num, bhash, err)
}
if b == nil {
t.Errorf("block %d (hash %v): not found", num, bhash)
continue
}
if b.Hash != bhash || uint64(b.Number) != num {
t.Errorf("block %d (hash %v): invalid number/hash", num, bhash)
}
}
}
func (s *historyTestSuite) testGetBlockByNumber(t *utesting.T) {
ctx := context.Background()
for i, num := range s.tests.BlockNumbers {
bhash := s.tests.BlockHashes[i]
b, err := s.cfg.client.getBlockByNumber(ctx, num, false)
if err != nil {
t.Fatalf("block %d (hash %v): error %v", num, bhash, err)
}
if b == nil {
t.Errorf("block %d (hash %v): not found", num, bhash)
continue
}
if b.Hash != bhash || uint64(b.Number) != num {
t.Errorf("block %d (hash %v): invalid number/hash", num, bhash)
}
}
}
func (s *historyTestSuite) testGetBlockTransactionCountByHash(t *utesting.T) {
ctx := context.Background()
for i, num := range s.tests.BlockNumbers {
bhash := s.tests.BlockHashes[i]
count, err := s.cfg.client.getBlockTransactionCountByHash(ctx, bhash)
if err != nil {
t.Fatalf("block %d (hash %v): error %v", num, bhash, err)
}
expectedCount := uint64(s.tests.TxCounts[i])
if count != expectedCount {
t.Errorf("block %d (hash %v): wrong txcount %d, want %d", count, expectedCount)
}
}
}
func (s *historyTestSuite) testGetBlockTransactionCountByNumber(t *utesting.T) {
ctx := context.Background()
for i, num := range s.tests.BlockNumbers {
bhash := s.tests.BlockHashes[i]
count, err := s.cfg.client.getBlockTransactionCountByNumber(ctx, num)
if err != nil {
t.Fatalf("block %d (hash %v): error %v", num, bhash, err)
}
expectedCount := uint64(s.tests.TxCounts[i])
if count != expectedCount {
t.Errorf("block %d (hash %v): wrong txcount %d, want %d", count, expectedCount)
}
}
}
func (s *historyTestSuite) testGetBlockReceiptsByHash(t *utesting.T) {
ctx := context.Background()
for i, num := range s.tests.BlockNumbers {
bhash := s.tests.BlockHashes[i]
receipts, err := s.cfg.client.getBlockReceipts(ctx, bhash)
if err != nil {
t.Fatalf("block %d (hash %v): error %v", num, bhash, err)
}
hash := calcReceiptsHash(receipts)
expectedHash := s.tests.ReceiptsHashes[i]
if hash != expectedHash {
t.Errorf("block %d (hash %v): wrong receipts hash %v, want %v", num, bhash, hash, expectedHash)
}
}
}
func (s *historyTestSuite) testGetBlockReceiptsByNumber(t *utesting.T) {
ctx := context.Background()
for i, num := range s.tests.BlockNumbers {
bhash := s.tests.BlockHashes[i]
receipts, err := s.cfg.client.getBlockReceipts(ctx, hexutil.Uint64(num))
if err != nil {
t.Fatalf("block %d (hash %v): error %v", num, bhash, err)
}
hash := calcReceiptsHash(receipts)
expectedHash := s.tests.ReceiptsHashes[i]
if hash != expectedHash {
t.Errorf("block %d (hash %v): wrong receipts hash %v, want %v", num, bhash, hash, expectedHash)
}
}
}
func (s *historyTestSuite) testGetTransactionByBlockHashAndIndex(t *utesting.T) {
ctx := context.Background()
for i, num := range s.tests.BlockNumbers {
bhash := s.tests.BlockHashes[i]
txIndex := s.tests.TxHashIndex[i]
expectedHash := s.tests.TxHashes[i]
if expectedHash == nil {
continue // no txs in block
}
tx, err := s.cfg.client.getTransactionByBlockHashAndIndex(ctx, bhash, uint64(txIndex))
if err != nil {
t.Fatalf("block %d (hash %v): error %v", num, bhash, err)
}
if tx == nil {
t.Errorf("block %d (hash %v): txIndex %d not found", num, bhash, txIndex)
continue
}
if tx.Hash != *expectedHash || uint64(tx.TransactionIndex) != uint64(txIndex) {
t.Errorf("block %d (hash %v): txIndex %d has wrong txHash/Index", num, bhash, txIndex)
}
}
}
func (s *historyTestSuite) testGetTransactionByBlockNumberAndIndex(t *utesting.T) {
ctx := context.Background()
for i, num := range s.tests.BlockNumbers {
bhash := s.tests.BlockHashes[i]
txIndex := s.tests.TxHashIndex[i]
expectedHash := s.tests.TxHashes[i]
if expectedHash == nil {
continue // no txs in block
}
tx, err := s.cfg.client.getTransactionByBlockNumberAndIndex(ctx, num, uint64(txIndex))
if err != nil {
t.Fatalf("block %d (hash %v): error %v", num, bhash, err)
}
if tx == nil {
t.Errorf("block %d (hash %v): txIndex %d not found", num, bhash, txIndex)
continue
}
if tx.Hash != *expectedHash || uint64(tx.TransactionIndex) != uint64(txIndex) {
t.Errorf("block %d (hash %v): txIndex %d has wrong txHash/Index", num, bhash, txIndex)
}
}
}
type simpleBlock struct {
Number hexutil.Uint64 `json:"number"`
Hash common.Hash `json:"hash"`
}
type simpleTransaction struct {
Hash common.Hash `json:"hash"`
TransactionIndex hexutil.Uint64 `json:"transactionIndex"`
}
func (c *client) getBlockByHash(ctx context.Context, arg common.Hash, fullTx bool) (*simpleBlock, error) {
var r *simpleBlock
err := c.RPC.CallContext(ctx, &r, "eth_getBlockByHash", arg, fullTx)
return r, err
}
func (c *client) getBlockByNumber(ctx context.Context, arg uint64, fullTx bool) (*simpleBlock, error) {
var r *simpleBlock
err := c.RPC.CallContext(ctx, &r, "eth_getBlockByNumber", hexutil.Uint64(arg), fullTx)
return r, err
}
func (c *client) getTransactionByBlockHashAndIndex(ctx context.Context, block common.Hash, index uint64) (*simpleTransaction, error) {
var r *simpleTransaction
err := c.RPC.CallContext(ctx, &r, "eth_getTransactionByBlockHashAndIndex", block, hexutil.Uint64(index))
return r, err
}
func (c *client) getTransactionByBlockNumberAndIndex(ctx context.Context, block uint64, index uint64) (*simpleTransaction, error) {
var r *simpleTransaction
err := c.RPC.CallContext(ctx, &r, "eth_getTransactionByBlockNumberAndIndex", hexutil.Uint64(block), hexutil.Uint64(index))
return r, err
}
func (c *client) getBlockTransactionCountByHash(ctx context.Context, block common.Hash) (uint64, error) {
var r hexutil.Uint64
err := c.RPC.CallContext(ctx, &r, "eth_getBlockTransactionCountByHash", block)
return uint64(r), err
}
func (c *client) getBlockTransactionCountByNumber(ctx context.Context, block uint64) (uint64, error) {
var r hexutil.Uint64
err := c.RPC.CallContext(ctx, &r, "eth_getBlockTransactionCountByNumber", hexutil.Uint64(block))
return uint64(r), err
}
func (c *client) getBlockReceipts(ctx context.Context, arg any) ([]*types.Receipt, error) {
var result []*types.Receipt
err := c.RPC.CallContext(ctx, &result, "eth_getBlockReceipts", arg)
return result, err
}

View file

@ -0,0 +1,147 @@
// Copyright 2025 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"context"
"encoding/json"
"fmt"
"math/big"
"os"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/internal/flags"
"github.com/ethereum/go-ethereum/rlp"
"github.com/urfave/cli/v2"
)
var (
historyGenerateCommand = &cli.Command{
Name: "historygen",
Usage: "Generates history retrieval tests",
ArgsUsage: "<RPC endpoint URL>",
Action: generateHistoryTests,
Flags: []cli.Flag{
historyTestFileFlag,
historyTestEarliestFlag,
},
}
historyTestFileFlag = &cli.StringFlag{
Name: "history-tests",
Usage: "JSON file containing filter test queries",
Value: "history_tests.json",
Category: flags.TestingCategory,
}
historyTestEarliestFlag = &cli.IntFlag{
Name: "earliest",
Usage: "JSON file containing filter test queries",
Value: 0,
Category: flags.TestingCategory,
}
)
const historyTestBlockCount = 2000
func generateHistoryTests(clictx *cli.Context) error {
var (
client = makeClient(clictx)
earliest = uint64(clictx.Int(historyTestEarliestFlag.Name))
outputFile = clictx.String(historyTestFileFlag.Name)
ctx = context.Background()
)
test := new(historyTest)
// Create the block numbers. Here we choose 1k blocks between earliest and head.
latest, err := client.Eth.BlockNumber(ctx)
if err != nil {
exit(err)
}
if latest < historyTestBlockCount {
exit(fmt.Errorf("node seems not synced, latest block is %d", latest))
}
test.BlockNumbers = make([]uint64, 0, historyTestBlockCount)
stride := (latest - earliest) / historyTestBlockCount
for b := earliest; b < latest; b += stride {
test.BlockNumbers = append(test.BlockNumbers, b)
}
// Get blocks and assign block info into the test
fmt.Println("Fetching blocks")
blocks := make([]*types.Block, len(test.BlockNumbers))
for i, blocknum := range test.BlockNumbers {
b, err := client.Eth.BlockByNumber(ctx, new(big.Int).SetUint64(blocknum))
if err != nil {
exit(fmt.Errorf("error fetching block %d: %v", blocknum, err))
}
blocks[i] = b
}
test.BlockHashes = make([]common.Hash, len(blocks))
test.TxCounts = make([]int, len(blocks))
for i, block := range blocks {
test.BlockHashes[i] = block.Hash()
test.TxCounts[i] = len(block.Transactions())
}
// Fill tx index.
test.TxHashIndex = make([]int, len(blocks))
test.TxHashes = make([]*common.Hash, len(blocks))
for i, block := range blocks {
txs := block.Transactions()
if len(txs) == 0 {
continue
}
index := len(txs) / 2
txhash := txs[index].Hash()
test.TxHashIndex[i] = index
test.TxHashes[i] = &txhash
}
// Get receipts.
fmt.Println("Fetching receipts")
test.ReceiptsHashes = make([]common.Hash, len(blocks))
for i, blockHash := range test.BlockHashes {
receipts, err := client.getBlockReceipts(ctx, blockHash)
if err != nil {
exit(fmt.Errorf("error fetching block %v receipts: %v", blockHash, err))
}
test.ReceiptsHashes[i] = calcReceiptsHash(receipts)
}
// Write output file.
writeJSON(outputFile, test)
return nil
}
func calcReceiptsHash(rcpt []*types.Receipt) common.Hash {
h := crypto.NewKeccakState()
rlp.Encode(h, rcpt)
return common.Hash(h.Sum(nil))
}
func writeJSON(fileName string, value any) {
file, err := os.Create(fileName)
if err != nil {
exit(fmt.Errorf("Error creating %s: %v", fileName, err))
return
}
defer file.Close()
json.NewEncoder(file).Encode(value)
}

86
cmd/workload/main.go Normal file
View file

@ -0,0 +1,86 @@
// Copyright 2025 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"fmt"
"os"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/internal/debug"
"github.com/ethereum/go-ethereum/internal/flags"
"github.com/ethereum/go-ethereum/rpc"
"github.com/urfave/cli/v2"
)
var app = flags.NewApp("go-ethereum workload test tool")
func init() {
app.Flags = append(app.Flags, debug.Flags...)
app.Before = func(ctx *cli.Context) error {
flags.MigrateGlobalFlags(ctx)
return debug.Setup(ctx)
}
app.After = func(ctx *cli.Context) error {
debug.Exit()
return nil
}
app.CommandNotFound = func(ctx *cli.Context, cmd string) {
fmt.Fprintf(os.Stderr, "No such command: %s\n", cmd)
os.Exit(1)
}
// Add subcommands.
app.Commands = []*cli.Command{
runTestCommand,
historyGenerateCommand,
filterGenerateCommand,
filterPerfCommand,
}
}
func main() {
exit(app.Run(os.Args))
}
type client struct {
Eth *ethclient.Client
RPC *rpc.Client
}
func makeClient(ctx *cli.Context) *client {
if ctx.NArg() < 1 {
exit("missing RPC endpoint URL as command-line argument")
}
url := ctx.Args().First()
cl, err := rpc.Dial(url)
if err != nil {
exit(fmt.Errorf("Could not create RPC client at %s: %v", url, err))
}
return &client{
RPC: cl,
Eth: ethclient.NewClient(cl),
}
}
func exit(err any) {
if err == nil {
os.Exit(0)
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

145
cmd/workload/testsuite.go Normal file
View file

@ -0,0 +1,145 @@
// Copyright 2020 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"embed"
"io/fs"
"os"
"slices"
"github.com/ethereum/go-ethereum/internal/flags"
"github.com/ethereum/go-ethereum/internal/utesting"
"github.com/ethereum/go-ethereum/log"
"github.com/urfave/cli/v2"
)
//go:embed queries
var builtinTestFiles embed.FS
var (
runTestCommand = &cli.Command{
Name: "test",
Usage: "Runs workload tests against an RPC endpoint",
ArgsUsage: "<RPC endpoint URL>",
Action: runTestCmd,
Flags: []cli.Flag{
testPatternFlag,
testTAPFlag,
testSlowFlag,
testSepoliaFlag,
testMainnetFlag,
filterQueryFileFlag,
historyTestFileFlag,
},
}
testPatternFlag = &cli.StringFlag{
Name: "run",
Usage: "Pattern of test suite(s) to run",
Category: flags.TestingCategory,
}
testTAPFlag = &cli.BoolFlag{
Name: "tap",
Usage: "Output test results in TAP format",
Category: flags.TestingCategory,
}
testSlowFlag = &cli.BoolFlag{
Name: "slow",
Usage: "Enable slow tests",
Value: false,
Category: flags.TestingCategory,
}
testSepoliaFlag = &cli.BoolFlag{
Name: "sepolia",
Usage: "Use test cases for sepolia network",
Category: flags.TestingCategory,
}
testMainnetFlag = &cli.BoolFlag{
Name: "mainnet",
Usage: "Use test cases for mainnet network",
Category: flags.TestingCategory,
}
)
// testConfig holds the parameters for testing.
type testConfig struct {
client *client
fsys fs.FS
filterQueryFile string
historyTestFile string
}
func testConfigFromCLI(ctx *cli.Context) (cfg testConfig) {
flags.CheckExclusive(ctx, testMainnetFlag, testSepoliaFlag)
if (ctx.IsSet(testMainnetFlag.Name) || ctx.IsSet(testSepoliaFlag.Name)) && ctx.IsSet(filterQueryFileFlag.Name) {
exit(filterQueryFileFlag.Name + " cannot be used with " + testMainnetFlag.Name + " or " + testSepoliaFlag.Name)
}
// configure ethclient
cfg.client = makeClient(ctx)
// configure test files
switch {
case ctx.Bool(testMainnetFlag.Name):
cfg.fsys = builtinTestFiles
cfg.filterQueryFile = "queries/filter_queries_mainnet.json"
cfg.historyTestFile = "queries/history_mainnet.json"
case ctx.Bool(testSepoliaFlag.Name):
cfg.fsys = builtinTestFiles
cfg.filterQueryFile = "queries/filter_queries_sepolia.json"
cfg.historyTestFile = "queries/history_sepolia.json"
default:
cfg.fsys = os.DirFS(".")
cfg.filterQueryFile = ctx.String(filterQueryFileFlag.Name)
cfg.historyTestFile = ctx.String(historyTestFileFlag.Name)
}
return cfg
}
func runTestCmd(ctx *cli.Context) error {
cfg := testConfigFromCLI(ctx)
filterSuite := newFilterTestSuite(cfg)
historySuite := newHistoryTestSuite(cfg)
// Filter test cases.
tests := filterSuite.allTests()
tests = append(tests, historySuite.allTests()...)
if ctx.IsSet(testPatternFlag.Name) {
tests = utesting.MatchTests(tests, ctx.String(testPatternFlag.Name))
}
if !ctx.Bool(testSlowFlag.Name) {
tests = slices.DeleteFunc(tests, func(test utesting.Test) bool {
return test.Slow
})
}
// Disable logging unless explicitly enabled.
if !ctx.IsSet("verbosity") && !ctx.IsSet("vmodule") {
log.SetDefault(log.NewLogger(log.DiscardHandler()))
}
// Run the tests.
var run = utesting.RunTests
if ctx.Bool(testTAPFlag.Name) {
run = utesting.RunTAP
}
results := run(tests, os.Stdout)
if utesting.CountFailures(results) > 0 {
os.Exit(1)
}
return nil
}

View file

@ -295,3 +295,45 @@ func CheckEnvVars(ctx *cli.Context, flags []cli.Flag, prefix string) {
}
}
}
// CheckExclusive verifies that only a single instance of the provided flags was
// set by the user. Each flag might optionally be followed by a string type to
// specialize it further.
func CheckExclusive(ctx *cli.Context, args ...any) {
set := make([]string, 0, 1)
for i := 0; i < len(args); i++ {
// Make sure the next argument is a flag and skip if not set
flag, ok := args[i].(cli.Flag)
if !ok {
panic(fmt.Sprintf("invalid argument, not cli.Flag type: %T", args[i]))
}
// Check if next arg extends current and expand its name if so
name := flag.Names()[0]
if i+1 < len(args) {
switch option := args[i+1].(type) {
case string:
// Extended flag check, make sure value set doesn't conflict with passed in option
if ctx.String(flag.Names()[0]) == option {
name += "=" + option
set = append(set, "--"+name)
}
// shift arguments and continue
i++
continue
case cli.Flag:
default:
panic(fmt.Sprintf("invalid argument, not cli.Flag or string extension: %T", args[i+1]))
}
}
// Mark the flag if it's set
if ctx.IsSet(flag.Names()[0]) {
set = append(set, "--"+name)
}
}
if len(set) > 1 {
fmt.Fprintf(os.Stderr, "Flags %v can't be used at the same time", strings.Join(set, ", "))
os.Exit(1)
}
}