Skip to content

Commit

Permalink
cmd: mev ping tests (#3176)
Browse files Browse the repository at this point in the history
Add `charon test mev` command for pings towards MEV relays. In the future more tests towards MEV relays can be added.

category: feature
ticket: #3171
  • Loading branch information
KaloyanTanev authored Jul 25, 2024
1 parent 1faa1e5 commit 2d1b7e6
Show file tree
Hide file tree
Showing 5 changed files with 559 additions and 0 deletions.
13 changes: 13 additions & 0 deletions cmd/ascii.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

package cmd

// generator used - https://patorjk.com/software/taag/#p=display&f=Big

func peersASCII() []string {
return []string{
" ____ ",
Expand Down Expand Up @@ -35,6 +37,17 @@ func validatorASCII() []string {
}
}

func mevASCII() []string {
return []string{
"__ __ ________ __",
"| \\/ | ____\\ \\ / /",
"| \\ / | |__ \\ \\ / / ",
"| |\\/| | __| \\ \\/ / ",
"| | | | |____ \\ / ",
"|_| |_|______| \\/ ",
}
}

func categoryDefaultASCII() []string {
return []string{
" ",
Expand Down
1 change: 1 addition & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func New() *cobra.Command {
newTestPeersCmd(runTestPeers),
newTestBeaconCmd(runTestBeacon),
newTestValidatorCmd(runTestValidator),
newTestMEVCmd(runTestMEV),
),
newAddValidatorsCmd(runAddValidatorsSolo),
newViewClusterManifestCmd(runViewClusterManifest),
Expand Down
5 changes: 5 additions & 0 deletions cmd/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const (
peersTestCategory = "peers"
beaconTestCategory = "beacon"
validatorTestCategory = "validator"
mevTestCategory = "mev"
)

type testConfig struct {
Expand Down Expand Up @@ -70,6 +71,8 @@ func listTestCases(cmd *cobra.Command) []string {
testCaseNames = maps.Keys(supportedBeaconTestCases())
case validatorTestCategory:
testCaseNames = maps.Keys(supportedValidatorTestCases())
case mevTestCategory:
testCaseNames = maps.Keys(supportedMEVTestCases())
default:
log.Warn(cmd.Context(), "Unknown command for listing test cases", nil, z.Str("name", cmd.Name()))
}
Expand Down Expand Up @@ -196,6 +199,8 @@ func writeResultToWriter(res testCategoryResult, w io.Writer) error {
lines = append(lines, beaconASCII()...)
case validatorTestCategory:
lines = append(lines, validatorASCII()...)
case mevTestCategory:
lines = append(lines, mevASCII()...)
default:
lines = append(lines, categoryDefaultASCII()...)
}
Expand Down
263 changes: 263 additions & 0 deletions cmd/testmev.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1

package cmd

import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptrace"
"time"

"github.com/spf13/cobra"
"golang.org/x/exp/maps"
"golang.org/x/sync/errgroup"

"github.com/obolnetwork/charon/app/errors"
"github.com/obolnetwork/charon/app/z"
)

type testMEVConfig struct {
testConfig
Endpoints []string
}

type testCaseMEV func(context.Context, *testMEVConfig, string) testResult

const (
thresholdMEVMeasureAvg = 40 * time.Millisecond
thresholdMEVMeasureBad = 100 * time.Millisecond
)

func newTestMEVCmd(runFunc func(context.Context, io.Writer, testMEVConfig) error) *cobra.Command {
var config testMEVConfig

cmd := &cobra.Command{
Use: "mev",
Short: "Run multiple tests towards mev nodes",
Long: `Run multiple tests towards mev nodes. Verify that Charon can efficiently interact with MEV Node(s).`,
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, _ []string) error {
return mustOutputToFileOnQuiet(cmd)
},
RunE: func(cmd *cobra.Command, _ []string) error {
return runFunc(cmd.Context(), cmd.OutOrStdout(), config)
},
}

bindTestFlags(cmd, &config.testConfig)
bindTestMEVFlags(cmd, &config)

return cmd
}

func bindTestMEVFlags(cmd *cobra.Command, config *testMEVConfig) {
const endpoints = "endpoints"
cmd.Flags().StringSliceVar(&config.Endpoints, endpoints, nil, "[REQUIRED] Comma separated list of one or more MEV relay endpoint URLs.")
mustMarkFlagRequired(cmd, endpoints)
}

func supportedMEVTestCases() map[testCaseName]testCaseMEV {
return map[testCaseName]testCaseMEV{
{name: "ping", order: 1}: mevPingTest,
{name: "pingMeasure", order: 2}: mevPingMeasureTest,
}
}

func runTestMEV(ctx context.Context, w io.Writer, cfg testMEVConfig) (err error) {
testCases := supportedMEVTestCases()
queuedTests := filterTests(maps.Keys(testCases), cfg.testConfig)
if len(queuedTests) == 0 {
return errors.New("test case not supported")
}
sortTests(queuedTests)

timeoutCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()

testResultsChan := make(chan map[string][]testResult)
testResults := make(map[string][]testResult)
startTime := time.Now()

// run test suite for all mev nodes
go testAllMEVs(timeoutCtx, queuedTests, testCases, cfg, testResultsChan)

for result := range testResultsChan {
maps.Copy(testResults, result)
}

execTime := Duration{time.Since(startTime)}

// use highest score as score of all
var score categoryScore
for _, t := range testResults {
targetScore := calculateScore(t)
if score == "" || score > targetScore {
score = targetScore
}
}

res := testCategoryResult{
CategoryName: mevTestCategory,
Targets: testResults,
ExecutionTime: execTime,
Score: score,
}

if !cfg.Quiet {
err = writeResultToWriter(res, w)
if err != nil {
return err
}
}

if cfg.OutputToml != "" {
err = writeResultToFile(res, cfg.OutputToml)
if err != nil {
return err
}
}

return nil
}

func testAllMEVs(ctx context.Context, queuedTestCases []testCaseName, allTestCases map[testCaseName]testCaseMEV, conf testMEVConfig, allMEVsResCh chan map[string][]testResult) {
defer close(allMEVsResCh)
// run tests for all mev nodes
allMEVsRes := make(map[string][]testResult)
singleMEVResCh := make(chan map[string][]testResult)
group, _ := errgroup.WithContext(ctx)

for _, endpoint := range conf.Endpoints {
group.Go(func() error {
return testSingleMEV(ctx, queuedTestCases, allTestCases, conf, endpoint, singleMEVResCh)
})
}

doneReading := make(chan bool)
go func() {
for singlePeerRes := range singleMEVResCh {
maps.Copy(allMEVsRes, singlePeerRes)
}
doneReading <- true
}()

err := group.Wait()
if err != nil {
return
}
close(singleMEVResCh)
<-doneReading

allMEVsResCh <- allMEVsRes
}

func testSingleMEV(ctx context.Context, queuedTestCases []testCaseName, allTestCases map[testCaseName]testCaseMEV, cfg testMEVConfig, target string, resCh chan map[string][]testResult) error {
singleTestResCh := make(chan testResult)
allTestRes := []testResult{}

// run all mev tests for a mev node, pushing each completed test to the channel until all are complete or timeout occurs
go runMEVTest(ctx, queuedTestCases, allTestCases, cfg, target, singleTestResCh)
testCounter := 0
finished := false
for !finished {
var testName string
select {
case <-ctx.Done():
testName = queuedTestCases[testCounter].name
allTestRes = append(allTestRes, testResult{Name: testName, Verdict: testVerdictFail, Error: errTimeoutInterrupted})
finished = true
case result, ok := <-singleTestResCh:
if !ok {
finished = true
break
}
testName = queuedTestCases[testCounter].name
testCounter++
result.Name = testName
allTestRes = append(allTestRes, result)
}
}

resCh <- map[string][]testResult{target: allTestRes}

return nil
}

func runMEVTest(ctx context.Context, queuedTestCases []testCaseName, allTestCases map[testCaseName]testCaseMEV, cfg testMEVConfig, target string, ch chan testResult) {
defer close(ch)
for _, t := range queuedTestCases {
select {
case <-ctx.Done():
return
default:
ch <- allTestCases[t](ctx, &cfg, target)
}
}
}

func mevPingTest(ctx context.Context, _ *testMEVConfig, target string) testResult {
testRes := testResult{Name: "Ping"}

client := http.Client{}
targetEndpoint := fmt.Sprintf("%v/eth/v1/builder/status", target)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetEndpoint, nil)
if err != nil {
return failedTestResult(testRes, err)
}
resp, err := client.Do(req)
if err != nil {
return failedTestResult(testRes, err)
}
defer resp.Body.Close()

if resp.StatusCode > 399 {
return failedTestResult(testRes, errors.New("status code %v", z.Int("status_code", resp.StatusCode)))
}

testRes.Verdict = testVerdictOk

return testRes
}

func mevPingMeasureTest(ctx context.Context, _ *testMEVConfig, target string) testResult {
testRes := testResult{Name: "PingMeasure"}

var start time.Time
var firstByte time.Duration

trace := &httptrace.ClientTrace{
GotFirstResponseByte: func() {
firstByte = time.Since(start)
},
}

start = time.Now()
targetEndpoint := fmt.Sprintf("%v/eth/v1/builder/status", target)
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodGet, targetEndpoint, nil)
if err != nil {
return failedTestResult(testRes, err)
}

resp, err := http.DefaultTransport.RoundTrip(req)
if err != nil {
return failedTestResult(testRes, err)
}
defer resp.Body.Close()

if resp.StatusCode > 399 {
return failedTestResult(testRes, errors.New("status code %v", z.Int("status_code", resp.StatusCode)))
}

if firstByte > thresholdMEVMeasureBad {
testRes.Verdict = testVerdictBad
} else if firstByte > thresholdMEVMeasureAvg {
testRes.Verdict = testVerdictAvg
} else {
testRes.Verdict = testVerdictGood
}
testRes.Measurement = Duration{firstByte}.String()

return testRes
}
Loading

0 comments on commit 2d1b7e6

Please sign in to comment.