├── .tool-versions ├── spec ├── consts.go └── routines.go ├── test-env ├── env.example ├── grafana │ ├── provisioning │ │ ├── datasources │ │ │ └── prometheus.yml │ │ └── dashboards │ │ │ └── dashboard.yml │ └── dashboards │ │ └── eth2-monitor.json ├── mev-relays.json.example ├── validators.txt.example ├── prometheus │ └── prometheus.yml └── docker-compose.yml ├── pkg ├── utilities.go ├── profiling.go ├── set.go ├── reporting.go ├── cache.go ├── mev.go └── monitoring.go ├── Makefile ├── cmd ├── opts │ └── opts.go └── root.go ├── main.go ├── Dockerfile ├── .gitlab-ci.yml ├── .github └── workflows │ ├── golangci-lint.yml │ └── main.yml ├── go.mod ├── .gitignore ├── beaconchain └── service.go ├── README.md ├── LICENSE.txt └── go.sum /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.19.3 2 | -------------------------------------------------------------------------------- /spec/consts.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | const ( 4 | SLOTS_PER_EPOCH = 32 5 | SECONDS_PER_SLOT = 12 6 | ) 7 | -------------------------------------------------------------------------------- /test-env/env.example: -------------------------------------------------------------------------------- 1 | LOG_LEVEL=info 2 | BEACON_CHAIN_API=http://example.com:3500 3 | VALIDATOR_KEYS_FILE=/app/validators.txt 4 | MEV_RELAYS_FILE=/app/mev-relays.json 5 | -------------------------------------------------------------------------------- /pkg/utilities.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "github.com/rs/zerolog/log" 5 | ) 6 | 7 | func Must(err error) { 8 | if err != nil { 9 | log.Error().Stack().Err(err).Msg("Fatal error occurred") 10 | panic(err) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test-env/grafana/provisioning/datasources/prometheus.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | access: proxy 7 | url: http://prometheus:9090 8 | isDefault: true 9 | editable: true 10 | jsonData: 11 | timeInterval: "15s" 12 | -------------------------------------------------------------------------------- /test-env/mev-relays.json.example: -------------------------------------------------------------------------------- 1 | [ 2 | "https://0xaa58208899c6105603b74396734a6263cc7d947f444f396a90f7b7d3e65d102aec7e5e5291b27e08d02c50a050825c2f@hoodi.titanrelay.xyz", 3 | "https://0x98f0ef62f00780cf8eb06701a7d22725b9437d4768bb19b363e882ae87129945ec206ec2dc16933f31d983f8225772b6@hoodi.aestus.live" 4 | ] 5 | -------------------------------------------------------------------------------- /test-env/grafana/provisioning/dashboards/dashboard.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'Default' 5 | orgId: 1 6 | folder: '' 7 | type: file 8 | disableDeletion: false 9 | editable: true 10 | allowUiUpdates: true 11 | options: 12 | path: /var/lib/grafana/dashboards 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION = $(shell git describe --tags --abbrev=8 2>/dev/null) 2 | 3 | LDFLAGS += -X eth2-monitor/cmd.version=${VERSION} 4 | 5 | .PHONY: all build eth2-monitor 6 | all: build 7 | 8 | build: eth2-monitor 9 | 10 | eth2-monitor: 11 | -@mkdir -p bin 12 | -@rm -f bin/$@ 13 | go build -ldflags '$(LDFLAGS)' -o bin/$@ . 14 | -------------------------------------------------------------------------------- /pkg/profiling.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func Measure(handler func(), title string, args ...interface{}) { 11 | start := time.Now() 12 | handler() 13 | elapsed := time.Since(start) 14 | log.Debug().Msgf("⏱️ %s took %v", fmt.Sprintf(title, args...), elapsed) 15 | } 16 | -------------------------------------------------------------------------------- /test-env/validators.txt.example: -------------------------------------------------------------------------------- 1 | # Example validator public keys (replace with your actual keys): 2 | 0xa1daf19d432507b70fd83214aba105be66c81307d22b3d242cdecaaca5528c1b1e5e3cac5ef7f9e7456e9d202d0ec887 3 | 0xb2ebf20e543618c81ge94325bcb216cf77d92418e33e353efdfebfbbd6639d2c2f6f4dbdf0g0a8567f0ae313e1fd998 4 | 0xc3fc312f654729d92hf05436cdc327dg88e03529f44f464fgfefgfcgcee7740e3d3g7g5ecg1h1b9678g1bf424f2ge009 5 | -------------------------------------------------------------------------------- /test-env/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | evaluation_interval: 15s 4 | external_labels: 5 | monitor: 'eth2-monitor' 6 | 7 | alerting: 8 | alertmanagers: 9 | - static_configs: 10 | - targets: [] 11 | 12 | scrape_configs: 13 | - job_name: 'eth2-monitor' 14 | static_configs: 15 | - targets: ['eth2-monitor:1337'] 16 | labels: 17 | service: 'eth2-monitor' 18 | -------------------------------------------------------------------------------- /spec/routines.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import "github.com/attestantio/go-eth2-client/spec/phase0" 4 | 5 | func EpochLowestSlot(epoch phase0.Epoch) phase0.Slot { 6 | return phase0.Slot(epoch * SLOTS_PER_EPOCH) 7 | } 8 | 9 | func EpochHighestSlot(epoch phase0.Epoch) phase0.Slot { 10 | return phase0.Slot(((epoch + 1) * SLOTS_PER_EPOCH) - 1) 11 | } 12 | 13 | func EpochFromSlot(slot phase0.Slot) phase0.Epoch { 14 | return phase0.Epoch(slot / SLOTS_PER_EPOCH) 15 | } 16 | -------------------------------------------------------------------------------- /cmd/opts/opts.go: -------------------------------------------------------------------------------- 1 | package opts 2 | 3 | var ( 4 | LogLevel string 5 | BeaconNode string 6 | BeaconChainAPI string 7 | MetricsPort string 8 | SlackURL string 9 | SlackUsername string 10 | 11 | Monitor struct { 12 | ReplayEpoch []uint 13 | SinceEpoch uint64 14 | PrintSuccessful bool 15 | DistanceTolerance uint64 16 | UseAbsoluteDistance bool 17 | MEVRelaysFilePath string 18 | 19 | Pubkeys []string 20 | } 21 | 22 | Slashings struct { 23 | ShowSlashingReward bool 24 | TwitterConsumerKey string 25 | TwitterConsumerSecret string 26 | TwitterAccessToken string 27 | TwitterAccessSecret string 28 | } 29 | ) 30 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "eth2-monitor/cmd" 9 | 10 | isatty "github.com/mattn/go-isatty" 11 | "github.com/rs/zerolog" 12 | "github.com/rs/zerolog/log" 13 | "github.com/rs/zerolog/pkgerrors" 14 | ) 15 | 16 | func init() { 17 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack 18 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 19 | log.Logger = log.Output(zerolog.ConsoleWriter{ 20 | Out: os.Stdout, 21 | TimeFormat: time.RFC3339Nano, 22 | NoColor: !isatty.IsTerminal(os.Stdout.Fd()), 23 | }) 24 | } 25 | 26 | func main() { 27 | if err := cmd.Execute(); err != nil { 28 | fmt.Println(err) 29 | os.Exit(1) 30 | } 31 | 32 | os.Exit(0) 33 | } 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine3.19 AS builder 2 | 3 | RUN apk update && \ 4 | apk add --no-cache ca-certificates && \ 5 | update-ca-certificates 6 | 7 | RUN adduser -D -g '' appuser 8 | 9 | WORKDIR /app 10 | 11 | ENV CGO_ENABLED=0 12 | 13 | COPY go.mod . 14 | COPY go.sum . 15 | RUN go mod download 16 | RUN go mod verify 17 | 18 | COPY . . 19 | RUN go build -o /go/bin/eth2-monitor -ldflags '-extldflags "-static"' 20 | 21 | # second step to build minimal image 22 | FROM alpine:3.21.3 23 | 24 | # add common trusted certificates from the build stage 25 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 26 | COPY --from=builder /etc/passwd /etc/passwd 27 | 28 | USER appuser 29 | 30 | COPY --from=builder /go/bin/eth2-monitor /go/bin/eth2-monitor 31 | 32 | ENTRYPOINT ["/go/bin/eth2-monitor"] 33 | -------------------------------------------------------------------------------- /pkg/set.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "iter" 6 | "strings" 7 | ) 8 | 9 | type Set[E comparable] map[E]struct{} 10 | 11 | func NewSet[E comparable](vals ...E) Set[E] { 12 | s := Set[E]{} 13 | for _, v := range vals { 14 | s[v] = struct{}{} 15 | } 16 | return s 17 | } 18 | 19 | func (s Set[E]) Add(vals ...E) { 20 | for _, v := range vals { 21 | s[v] = struct{}{} 22 | } 23 | } 24 | 25 | func (s Set[E]) Contains(v E) bool { 26 | _, ok := s[v] 27 | return ok 28 | } 29 | 30 | func (s Set[E]) Remove(v E) { 31 | delete(s, v) 32 | } 33 | 34 | func (s Set[E]) IsEmpty() bool { 35 | return len(s) == 0 36 | } 37 | 38 | func (s Set[E]) String() string { 39 | var sb strings.Builder 40 | first := true 41 | sb.WriteString("{") 42 | for v := range s { 43 | if !first { 44 | sb.WriteString(" ") 45 | } 46 | sb.WriteString(fmt.Sprint(v)) 47 | first = false 48 | } 49 | sb.WriteString("}") 50 | return sb.String() 51 | } 52 | 53 | func (s Set[E]) Elems() iter.Seq[E] { 54 | return func(yield func(E) bool) { 55 | for v := range s { 56 | if !yield(v) { 57 | return 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: golang 2 | 3 | stages: 4 | - build 5 | - build-image 6 | 7 | build: 8 | stage: build 9 | script: 10 | - GOOS=linux GOARCH=amd64 make eth2-monitor && mv bin/eth2-monitor{,-linux-amd64} 11 | - GOOS=darwin GOARCH=amd64 make eth2-monitor && mv bin/eth2-monitor{,-darwin-amd64} 12 | - GOOS=freebsd GOARCH=amd64 make eth2-monitor && mv bin/eth2-monitor{,-freebsd-amd64} 13 | - GOOS=windows GOARCH=amd64 make eth2-monitor && mv bin/eth2-monitor{,-windows-amd64} 14 | artifacts: 15 | paths: 16 | - bin/ 17 | 18 | build-image: 19 | stage: build-image 20 | image: 21 | name: gcr.io/kaniko-project/executor:v1.7.0-debug 22 | entrypoint: [""] 23 | script: 24 | - mkdir -p /kaniko/.docker 25 | - echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(printf "%s:%s" "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json 26 | - >- 27 | /kaniko/executor 28 | --context "${CI_PROJECT_DIR}" 29 | --dockerfile "${CI_PROJECT_DIR}/Dockerfile" 30 | --destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}" 31 | rules: 32 | - if: $CI_COMMIT_TAG 33 | -------------------------------------------------------------------------------- /pkg/reporting.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "eth2-monitor/cmd/opts" 7 | "fmt" 8 | "net/http" 9 | 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | func Report(format string, args ...interface{}) { 14 | message := fmt.Sprintf(format, args...) 15 | 16 | log.Warn().Msg(message) 17 | 18 | reportToSlack(message) 19 | } 20 | 21 | func Info(format string, args ...interface{}) { 22 | message := fmt.Sprintf(format, args...) 23 | 24 | log.Info().Msg(message) 25 | 26 | reportToSlack(message) 27 | } 28 | 29 | func reportToSlack(message string) { 30 | if opts.SlackURL == "" { 31 | return 32 | } 33 | 34 | var body struct { 35 | Text string `json:"text"` 36 | Username *string `json:"username"` 37 | } 38 | body.Text = message 39 | if opts.SlackUsername != "" { 40 | body.Username = &opts.SlackUsername 41 | } 42 | 43 | buf, err := json.Marshal(body) 44 | if err != nil { 45 | log.Warn().Err(err).Msgf("json.Marshal failed while reporting %q; skip", message) 46 | } 47 | 48 | resp, err := http.Post(opts.SlackURL, "application/json", bytes.NewBuffer([]byte(buf))) 49 | if err != nil { 50 | log.Warn().Err(err).Msgf("http.Post failed while reporting %q; skip", message) 51 | } 52 | defer resp.Body.Close() 53 | } 54 | -------------------------------------------------------------------------------- /test-env/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | eth2-monitor: 5 | build: 6 | context: .. 7 | dockerfile: Dockerfile 8 | restart: unless-stopped 9 | environment: 10 | - LOG_LEVEL=${LOG_LEVEL:-info} 11 | - BEACON_CHAIN_API=${BEACON_CHAIN_API} 12 | - VALIDATOR_KEYS_FILE=${VALIDATOR_KEYS_FILE} 13 | - MEV_RELAYS_FILE=${MEV_RELAYS_FILE} 14 | command: 15 | - "monitor" 16 | - "--beacon-chain-api" 17 | - "${BEACON_CHAIN_API}" 18 | - "--metrics-port" 19 | - "1337" 20 | - "--log-level" 21 | - "${LOG_LEVEL}" 22 | - "${VALIDATOR_KEYS_FILE}" 23 | - "--mev-relays" 24 | - "${MEV_RELAYS_FILE}" 25 | volumes: 26 | - ./validators.txt:/app/validators.txt:ro 27 | - ./mev-relays.json:/app/mev-relays.json:ro 28 | depends_on: 29 | - prometheus 30 | 31 | prometheus: 32 | image: prom/prometheus:latest 33 | restart: unless-stopped 34 | command: 35 | - '--config.file=/etc/prometheus/prometheus.yml' 36 | - '--storage.tsdb.path=/prometheus' 37 | - '--storage.tsdb.retention.time=30d' 38 | - '--web.console.libraries=/usr/share/prometheus/console_libraries' 39 | - '--web.console.templates=/usr/share/prometheus/consoles' 40 | - '--web.enable-lifecycle' 41 | volumes: 42 | - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro 43 | - ./prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro 44 | - ./data/prometheus:/prometheus 45 | ports: 46 | - "9090:9090" 47 | 48 | grafana: 49 | image: grafana/grafana:latest 50 | restart: unless-stopped 51 | environment: 52 | - GF_SECURITY_ADMIN_USER=admin 53 | - GF_SECURITY_ADMIN_PASSWORD=admin 54 | - GF_USERS_ALLOW_SIGN_UP=false 55 | - GF_SERVER_ROOT_URL=http://localhost:3000 56 | volumes: 57 | - ./data/grafana:/var/lib/grafana 58 | - ./grafana/provisioning:/etc/grafana/provisioning:ro 59 | - ./grafana/dashboards:/var/lib/grafana/dashboards:ro 60 | ports: 61 | - "3000:3000" 62 | depends_on: 63 | - prometheus 64 | -------------------------------------------------------------------------------- /pkg/cache.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | "path" 8 | "time" 9 | 10 | "github.com/attestantio/go-eth2-client/spec/phase0" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | type CachedIndex struct { 15 | Index phase0.ValidatorIndex 16 | At time.Time 17 | } 18 | 19 | type LocalCache struct { 20 | Validators map[string]CachedIndex 21 | } 22 | 23 | var ( 24 | cacheFilePath = path.Join(os.TempDir(), "stakefish-eth2-monitor-cache.json") 25 | ) 26 | 27 | func LoadCache() *LocalCache { 28 | cache := &LocalCache{ 29 | Validators: make(map[string]CachedIndex), 30 | } 31 | 32 | log.Trace().Msgf("Validator Index Cache Path %v", cacheFilePath) 33 | 34 | fd, err := os.Open(cacheFilePath) 35 | if err != nil { 36 | log.Debug().Err(err).Msg("LoadCache: os.Open failed; skip") 37 | return cache 38 | } 39 | defer fd.Close() 40 | 41 | rawCache, err := io.ReadAll(fd) 42 | if err != nil { 43 | log.Debug().Err(err).Msg("LoadCache: io.ReadAll failed; skip") 44 | return cache 45 | } 46 | err = json.Unmarshal(rawCache, cache) 47 | if err != nil { 48 | log.Error().Err(err).Msg("LoadCache: json.Unmarshal failed; returning empty cache") 49 | } 50 | 51 | return cache 52 | } 53 | 54 | func SaveCache(newCache *LocalCache) { 55 | // Merge with the current cache. 56 | cache := LoadCache() 57 | for pubkey, validator := range newCache.Validators { 58 | validator := validator 59 | cache.Validators[pubkey] = validator 60 | } 61 | 62 | rawCache, err := json.MarshalIndent(cache, "", " ") 63 | if err != nil { 64 | log.Debug().Err(err).Msg("SaveCache: json.MarshalIndent failed; skip") 65 | return 66 | } 67 | 68 | tmpfile, err := os.CreateTemp("", "stakefish-eth2-monitor-cache.*.json") 69 | if err != nil { 70 | log.Warn().Err(err).Msg("SaveCache: os.CreateTemp failed; skip") 71 | return 72 | } 73 | defer os.Remove(tmpfile.Name()) 74 | 75 | for bytesWritten := 0; bytesWritten < len(rawCache); { 76 | nWritten, err := tmpfile.Write(rawCache[bytesWritten:]) 77 | if err != nil && err != io.ErrShortWrite { 78 | log.Debug().Err(err).Msg("SaveCache: tmpfile.Write failed; skip") 79 | break 80 | } 81 | bytesWritten += nWritten 82 | } 83 | err = os.Rename(tmpfile.Name(), cacheFilePath) 84 | if err != nil { 85 | log.Error().Err(err).Msg("SaveCache: os.Rename failed; skip") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read 6 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 7 | # pull-requests: read 8 | # Optional: Allow write access to checks to allow the action to annotate code in the PR. 9 | checks: write 10 | 11 | jobs: 12 | golangci: 13 | name: lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-go@v5 18 | with: 19 | go-version: '1.23' 20 | cache: false 21 | - name: golangci-lint 22 | uses: golangci/golangci-lint-action@v4 23 | with: 24 | # Require: The version of golangci-lint to use. 25 | # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. 26 | # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. 27 | version: v1.60 28 | 29 | # XXX Workaround for https://github.com/golangci/golangci-lint-action/issues/135 30 | # Optional: if set to true, then the action won't cache or restore ~/go/pkg. 31 | skip-pkg-cache: true 32 | 33 | # Optional: working directory, useful for monorepos 34 | # working-directory: somedir 35 | 36 | # Optional: golangci-lint command line arguments. 37 | # 38 | # Note: By default, the `.golangci.yml` file should be at the root of the repository. 39 | # The location of the configuration file can be changed by using `--config=` 40 | # args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0 41 | 42 | # Optional: show only new issues if it's a pull request. The default value is `false`. 43 | # only-new-issues: true 44 | 45 | # Optional: if set to true, then all caching functionality will be completely disabled, 46 | # takes precedence over all other caching options. 47 | # skip-cache: true 48 | 49 | # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. 50 | # skip-build-cache: true 51 | 52 | # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. 53 | # install-mode: "goinstall" 54 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module eth2-monitor 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/attestantio/go-eth2-client v0.27.1 7 | github.com/mattn/go-isatty v0.0.20 8 | github.com/pkg/errors v0.9.1 9 | github.com/prometheus/client_golang v1.16.0 10 | github.com/rs/zerolog v1.32.0 11 | github.com/spf13/cobra v1.5.0 12 | golang.org/x/sync v0.11.0 13 | ) 14 | 15 | require ( 16 | github.com/beorn7/perks v1.0.1 // indirect 17 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 18 | github.com/emicklei/dot v1.6.4 // indirect 19 | github.com/fatih/color v1.16.0 // indirect 20 | github.com/ferranbt/fastssz v0.1.4 // indirect 21 | github.com/go-logr/logr v1.2.4 // indirect 22 | github.com/go-logr/stdr v1.2.2 // indirect 23 | github.com/go-playground/validator/v10 v10.13.0 // indirect 24 | github.com/goccy/go-yaml v1.9.2 // indirect 25 | github.com/golang/protobuf v1.5.3 // indirect 26 | github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect 27 | github.com/holiman/uint256 v1.3.2 // indirect 28 | github.com/huandu/go-clone v1.6.0 // indirect 29 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 30 | github.com/klauspost/cpuid/v2 v2.2.9 // indirect 31 | github.com/mattn/go-colorable v0.1.14 // indirect 32 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 33 | github.com/minio/sha256-simd v1.0.1 // indirect 34 | github.com/mitchellh/mapstructure v1.5.0 // indirect 35 | github.com/pk910/dynamic-ssz v0.0.4 // indirect 36 | github.com/prometheus/client_model v0.4.0 // indirect 37 | github.com/prometheus/common v0.42.0 // indirect 38 | github.com/prometheus/procfs v0.10.1 // indirect 39 | github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 // indirect 40 | github.com/r3labs/sse/v2 v2.10.0 // indirect 41 | github.com/spf13/pflag v1.0.5 // indirect 42 | go.opentelemetry.io/otel v1.16.0 // indirect 43 | go.opentelemetry.io/otel/metric v1.16.0 // indirect 44 | go.opentelemetry.io/otel/trace v1.16.0 // indirect 45 | golang.org/x/crypto v0.33.0 // indirect 46 | golang.org/x/net v0.21.0 // indirect 47 | golang.org/x/sys v0.30.0 // indirect 48 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 49 | google.golang.org/protobuf v1.33.0 // indirect 50 | gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect 51 | gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect 52 | gopkg.in/yaml.v2 v2.4.0 // indirect 53 | ) 54 | 55 | // See https://github.com/prysmaticlabs/prysm/blob/d035be29cd549ca38b257e67bb6d9e6e76e9fba7/go.mod#L270 56 | replace github.com/grpc-ecosystem/grpc-gateway/v2 => github.com/prysmaticlabs/grpc-gateway/v2 v2.3.1-0.20230315201114-09284ba20446 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/go,emacs,macos,linux,vim 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=go,emacs,macos,linux,vim 4 | 5 | ### Emacs ### 6 | # -*- mode: gitignore; -*- 7 | *~ 8 | \#*\# 9 | /.emacs.desktop 10 | /.emacs.desktop.lock 11 | *.elc 12 | auto-save-list 13 | tramp 14 | .\#* 15 | 16 | # Org-mode 17 | .org-id-locations 18 | *_archive 19 | 20 | # flymake-mode 21 | *_flymake.* 22 | 23 | # eshell files 24 | /eshell/history 25 | /eshell/lastdir 26 | 27 | # elpa packages 28 | /elpa/ 29 | 30 | # reftex files 31 | *.rel 32 | 33 | # AUCTeX auto folder 34 | /auto/ 35 | 36 | # cask packages 37 | .cask/ 38 | dist/ 39 | 40 | # Flycheck 41 | flycheck_*.el 42 | 43 | # server auth directory 44 | /server/ 45 | 46 | # projectiles files 47 | .projectile 48 | 49 | # directory configuration 50 | .dir-locals.el 51 | 52 | # network security 53 | /network-security.data 54 | 55 | 56 | ### Go ### 57 | # Binaries for programs and plugins 58 | *.exe 59 | *.exe~ 60 | *.dll 61 | *.so 62 | *.dylib 63 | 64 | bin 65 | 66 | # Test binary, built with `go test -c` 67 | *.test 68 | 69 | # Output of the go coverage tool, specifically when used with LiteIDE 70 | *.out 71 | 72 | # Dependency directories (remove the comment below to include it) 73 | # vendor/ 74 | 75 | ### Go Patch ### 76 | /vendor/ 77 | /Godeps/ 78 | 79 | ### Linux ### 80 | 81 | # temporary files which can be created if a process still has a handle open of a deleted file 82 | .fuse_hidden* 83 | 84 | # KDE directory preferences 85 | .directory 86 | 87 | # Linux trash folder which might appear on any partition or disk 88 | .Trash-* 89 | 90 | # .nfs files are created when an open file is removed but is still being accessed 91 | .nfs* 92 | 93 | ### macOS ### 94 | # General 95 | .DS_Store 96 | .AppleDouble 97 | .LSOverride 98 | 99 | # Icon must end with two \r 100 | Icon 101 | 102 | # Thumbnails 103 | ._* 104 | 105 | # Files that might appear in the root of a volume 106 | .DocumentRevisions-V100 107 | .fseventsd 108 | .Spotlight-V100 109 | .TemporaryItems 110 | .Trashes 111 | .VolumeIcon.icns 112 | .com.apple.timemachine.donotpresent 113 | 114 | # Directories potentially created on remote AFP share 115 | .AppleDB 116 | .AppleDesktop 117 | Network Trash Folder 118 | Temporary Items 119 | .apdisk 120 | 121 | ### Vim ### 122 | # Swap 123 | [._]*.s[a-v][a-z] 124 | !*.svg # comment out if you don't need vector files 125 | [._]*.sw[a-p] 126 | [._]s[a-rt-v][a-z] 127 | [._]ss[a-gi-z] 128 | [._]sw[a-p] 129 | 130 | # Session 131 | Session.vim 132 | Sessionx.vim 133 | 134 | # Temporary 135 | .netrwhist 136 | # Auto-generated tag files 137 | tags 138 | # Persistent undo 139 | [._]*.un~ 140 | 141 | # End of https://www.toptal.com/developers/gitignore/api/go,emacs,macos,linux,vim 142 | 143 | log.* 144 | *.log 145 | *.txt 146 | eth2-monitor 147 | !LICENSE.txt 148 | 149 | # Test environment data and runtime files 150 | test-env/data/ 151 | test-env/mev-relays.json 152 | test-env/.env 153 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: CI 3 | env: 4 | REGISTRY: ghcr.io 5 | IMAGE_NAME: ${{ github.repository }} 6 | jobs: 7 | # XXX: Static analysis step is way too out of date. 8 | # static-analysis: 9 | # name: Static Analysis 10 | # runs-on: ubuntu-latest 11 | # steps: 12 | # - uses: actions/checkout@v3 13 | # - name: go vet 14 | # continue-on-error: false 15 | # uses: grandcolline/golang-github-actions@v1.1.0 16 | # with: 17 | # run: vet 18 | # - name: staticcheck 19 | # continue-on-error: false 20 | # uses: grandcolline/golang-github-actions@v1.1.0 21 | # with: 22 | # run: staticcheck 23 | # - name: gosec 24 | # # Gives too many false positives. 25 | # continue-on-error: true 26 | # uses: grandcolline/golang-github-actions@v1.1.0 27 | # with: 28 | # run: sec 29 | # flags: "-exclude=G104" 30 | # - name: misspell 31 | # continue-on-error: false 32 | # run: | 33 | # go get -u github.com/client9/misspell/cmd/misspell 34 | # $(go env GOPATH)/bin/misspell -locale US *.md $(find . -name '*.go') 35 | build: 36 | name: Build the executable 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/setup-go@v3 40 | with: 41 | go-version: 1.23.x 42 | - uses: actions/checkout@v3 43 | - name: Build 44 | run: | 45 | set -ex 46 | 47 | build () { 48 | GOARCH=$1 GOOS=$2 make eth2-monitor && install -v bin/eth2-monitor build/eth2-monitor-$1 49 | } 50 | 51 | mkdir -p build 52 | for arch in arm64 arm64; do 53 | for os in linux darwin freebsd windows; do 54 | build "$arch" "$os" 55 | done 56 | done 57 | 58 | ( cd build 59 | sha256sum * > sha256sums.txt 60 | ) 61 | 62 | - name: Upload binaries 63 | uses: actions/upload-artifact@master 64 | with: 65 | name: Executables 66 | path: "build" 67 | - name: Release 68 | uses: softprops/action-gh-release@v1 69 | if: startsWith(github.ref, 'refs/tags/v') 70 | with: 71 | files: | 72 | build/* 73 | env: 74 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 75 | build-and-push-image: 76 | runs-on: ubuntu-latest 77 | # needs: [static-analysis, build] 78 | needs: [build] 79 | if: startsWith(github.ref, 'refs/tags/') 80 | permissions: 81 | contents: read 82 | packages: write 83 | attestations: write 84 | id-token: write 85 | steps: 86 | - name: Checkout repository 87 | uses: actions/checkout@v5 88 | - name: Log in to the Container registry 89 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 90 | with: 91 | registry: ${{ env.REGISTRY }} 92 | username: ${{ github.actor }} 93 | password: ${{ secrets.GITHUB_TOKEN }} 94 | - name: Extract metadata (tags, labels) for Docker 95 | id: meta 96 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 97 | with: 98 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 99 | - name: Build and push Docker image 100 | id: push 101 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 102 | with: 103 | context: . 104 | push: true 105 | tags: ${{ steps.meta.outputs.tags }} 106 | labels: ${{ steps.meta.outputs.labels }} 107 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | "eth2-monitor/beaconchain" 11 | "eth2-monitor/cmd/opts" 12 | "eth2-monitor/pkg" 13 | 14 | "github.com/attestantio/go-eth2-client/spec/phase0" 15 | "github.com/rs/zerolog" 16 | "github.com/rs/zerolog/log" 17 | "github.com/spf13/cobra" 18 | 19 | "net/http" 20 | 21 | "github.com/prometheus/client_golang/prometheus/promhttp" 22 | ) 23 | 24 | var ( 25 | rootCmd = &cobra.Command{ 26 | Use: "eth2-monitor", 27 | Short: "Ethereum 2 performance monitor", 28 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 29 | if logLevel, err := zerolog.ParseLevel(opts.LogLevel); err != nil { 30 | fmt.Println(err) 31 | } else { 32 | zerolog.SetGlobalLevel(logLevel) 33 | } 34 | }, 35 | } 36 | 37 | versionCmd = &cobra.Command{ 38 | Use: "version", 39 | Short: "Print the version number of eth2-monitor", 40 | Args: cobra.NoArgs, 41 | Run: func(cmd *cobra.Command, args []string) { 42 | fmt.Printf("eth2-monitor %s\n", GetVersion()) 43 | }, 44 | } 45 | 46 | monitorCmd = &cobra.Command{ 47 | Use: "monitor [-k PUBKEY] [PUBKEY_FILES...]", 48 | Short: "Monitor attestations and proposals performance", 49 | Args: cobra.ArbitraryArgs, 50 | PreRunE: func(cmd *cobra.Command, args []string) error { 51 | if len(args)+len(opts.Monitor.Pubkeys) < 1 { 52 | return errors.New("provide validator public keys using -k or by specifying files with public keys") 53 | } 54 | return nil 55 | }, 56 | Run: func(cmd *cobra.Command, args []string) { 57 | ctx, cancel := context.WithCancel(context.Background()) 58 | defer cancel() 59 | 60 | beacon, err := beaconchain.New(ctx, opts.BeaconChainAPI, time.Minute) 61 | pkg.Must(err) 62 | 63 | plainPubkeys, err := pkg.LoadKeys(args) 64 | pkg.Must(err) 65 | if len(plainPubkeys) == 0 { 66 | panic("No validators to monitor") 67 | } 68 | log.Info().Msgf("Loaded validator keys: %v", len(plainPubkeys)) 69 | 70 | mevRelays := []string{} 71 | if opts.Monitor.MEVRelaysFilePath != "" { 72 | mevRelays, err = pkg.LoadMEVRelays(opts.Monitor.MEVRelaysFilePath) 73 | pkg.Must(err) 74 | log.Info().Msgf("Loaded MEV relays: %v", len(mevRelays)) 75 | } 76 | 77 | epochsChan := make(chan phase0.Epoch) 78 | 79 | var wg sync.WaitGroup 80 | wg.Add(2) 81 | go pkg.SubscribeToEpochs(ctx, beacon, &wg, epochsChan) 82 | go pkg.MonitorAttestationsAndProposals(ctx, beacon, plainPubkeys, mevRelays, &wg, epochsChan) 83 | 84 | //Create Prometheus Metrics Client 85 | http.Handle("/metrics", promhttp.Handler()) 86 | err = http.ListenAndServe(":"+opts.MetricsPort, nil) 87 | pkg.Must(err) 88 | 89 | defer wg.Wait() // XXX unreachable -- ListenAndServe() call above blocks 90 | }, 91 | } 92 | 93 | version = "" 94 | ) 95 | 96 | // GetVersion returns the semver string of the version 97 | func GetVersion() string { 98 | return version 99 | } 100 | 101 | // Execute executes the root command. 102 | func Execute() error { 103 | return rootCmd.Execute() 104 | } 105 | 106 | func init() { 107 | rootCmd.PersistentFlags().StringVarP(&opts.LogLevel, "log-level", "l", "info", "log level (error, warn, info, debug, trace)") 108 | rootCmd.PersistentFlags().StringVar(&opts.BeaconNode, "beacon-node", "localhost:4000", "Prysm beacon node GRPC address") 109 | rootCmd.PersistentFlags().StringVar(&opts.BeaconChainAPI, "beacon-chain-api", "localhost:3500", "Beacon Chain API HTTP address") 110 | rootCmd.PersistentFlags().StringVar(&opts.MetricsPort, "metrics-port", "1337", "Metrics port to expose metrics for Prometheus") 111 | rootCmd.PersistentFlags().StringVar(&opts.SlackURL, "slack-url", "", "Slack Webhook URL") 112 | rootCmd.PersistentFlags().StringVar(&opts.SlackUsername, "slack-username", "", "Slack username") 113 | 114 | rootCmd.AddCommand(versionCmd) 115 | 116 | monitorCmd.PersistentFlags().BoolVar(&opts.Monitor.PrintSuccessful, "print-successful", false, "print successful attestations") 117 | monitorCmd.PersistentFlags().UintSliceVar(&opts.Monitor.ReplayEpoch, "replay-epoch", nil, "replay epoch for debug purposes") 118 | monitorCmd.PersistentFlags().Uint64Var(&opts.Monitor.SinceEpoch, "since-epoch", ^uint64(0), "replay epochs from the specified one") 119 | monitorCmd.PersistentFlags().StringSliceVarP(&opts.Monitor.Pubkeys, "pubkey", "k", nil, "validator public key") 120 | monitorCmd.PersistentFlags().StringVar(&opts.Monitor.MEVRelaysFilePath, "mev-relays", "", "file path containing a one-per-line list of MEV relays to use in monitoring vanilla blocks") 121 | monitorCmd.PersistentFlags().Lookup("since-epoch").DefValue = "follows justified epoch" 122 | monitorCmd.PersistentFlags().SortFlags = false 123 | rootCmd.AddCommand(monitorCmd) 124 | } 125 | -------------------------------------------------------------------------------- /beaconchain/service.go: -------------------------------------------------------------------------------- 1 | package beaconchain 2 | 3 | import ( 4 | "context" 5 | "encoding/hex" 6 | "eth2-monitor/spec" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | eth2client "github.com/attestantio/go-eth2-client" 12 | "github.com/attestantio/go-eth2-client/api" 13 | apiv1 "github.com/attestantio/go-eth2-client/api/v1" 14 | eth2http "github.com/attestantio/go-eth2-client/http" 15 | "github.com/attestantio/go-eth2-client/spec/electra" 16 | "github.com/attestantio/go-eth2-client/spec/phase0" 17 | ) 18 | 19 | type BeaconChain struct { 20 | service eth2client.Service 21 | timeout time.Duration 22 | } 23 | 24 | func New(ctx context.Context, address string, timeout time.Duration) (*BeaconChain, error) { 25 | service, err := eth2http.New(ctx, eth2http.WithAddress(address), eth2http.WithTimeout(time.Minute)) 26 | 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | result := &BeaconChain{ 32 | service: service, 33 | timeout: timeout, 34 | } 35 | 36 | return result, nil 37 | } 38 | 39 | func (beacon *BeaconChain) Service() eth2client.Service { 40 | return beacon.service 41 | } 42 | 43 | func NormalizedPublicKey(pubkey string) string { 44 | if !strings.HasPrefix(pubkey, "0x") { 45 | panic(fmt.Sprintf("Public key did not have the expected 0x prefix: %v", pubkey)) 46 | } 47 | pubkey = strings.TrimPrefix(pubkey, "0x") 48 | pubkey = strings.ToLower(pubkey) 49 | return pubkey 50 | } 51 | 52 | func (beacon *BeaconChain) GetValidatorIndexes(ctx context.Context, pubkeys []string, epoch phase0.Epoch) (map[string]phase0.ValidatorIndex, error) { 53 | provider := beacon.service.(eth2client.ValidatorsProvider) 54 | 55 | blspubkeys := make([]phase0.BLSPubKey, len(pubkeys)) 56 | for i, strkey := range pubkeys { 57 | binkey, err := hex.DecodeString(strkey) 58 | if err != nil { 59 | return nil, err 60 | } 61 | blspubkeys[i] = phase0.BLSPubKey(binkey) 62 | } 63 | 64 | resp, err := provider.Validators(ctx, &api.ValidatorsOpts{ 65 | State: fmt.Sprintf("%d", spec.EpochLowestSlot(epoch)), 66 | PubKeys: blspubkeys, 67 | }) 68 | if err != nil { 69 | return nil, err 70 | } 71 | if len(resp.Data) > len(pubkeys) { 72 | panic(fmt.Sprintf("Expected at most %v validator in Beacon API response, got %v", len(pubkeys), len(resp.Data))) 73 | } 74 | 75 | result := map[string]phase0.ValidatorIndex{} 76 | for index, validator := range resp.Data { 77 | if validator.Status == apiv1.ValidatorStateActiveOngoing || validator.Status == apiv1.ValidatorStateActiveExiting || validator.Status == apiv1.ValidatorStateActiveSlashed { 78 | // Includes the leading 0x 79 | key := validator.Validator.PublicKey.String() 80 | key = NormalizedPublicKey(key) 81 | result[key] = index 82 | } 83 | } 84 | return result, nil 85 | } 86 | 87 | // Resolve slot number to a block 88 | func (beacon *BeaconChain) GetBlockHeader(ctx context.Context, slot phase0.Slot) (*apiv1.BeaconBlockHeader, error) { 89 | provider := beacon.Service().(eth2client.BeaconBlockHeadersProvider) 90 | 91 | resp, err := provider.BeaconBlockHeader(ctx, &api.BeaconBlockHeaderOpts{ 92 | Block: fmt.Sprintf("%v", slot), 93 | }) 94 | 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | if resp == nil { 100 | // Missed slot 101 | return nil, nil 102 | } 103 | 104 | return resp.Data, err 105 | } 106 | 107 | // Get block payload 108 | func (beacon *BeaconChain) GetBlock(ctx context.Context, slot phase0.Slot) (*electra.SignedBeaconBlock, error) { 109 | provider := beacon.Service().(eth2client.SignedBeaconBlockProvider) 110 | 111 | resp, err := provider.SignedBeaconBlock(ctx, &api.SignedBeaconBlockOpts{ 112 | Block: fmt.Sprintf("%v", slot), 113 | }) 114 | 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | if resp == nil { 120 | // Missed slot 121 | return nil, nil 122 | } 123 | 124 | // Fulu uses the same electra.SignedBeaconBlock structure 125 | if resp.Data.Fulu == nil { 126 | return nil, fmt.Errorf("unsupported block version at slot %v: Fulu block not found (expected Fusaka)", slot) 127 | } 128 | 129 | return resp.Data.Fulu, nil 130 | } 131 | 132 | func (beacon *BeaconChain) GetProposerDuties(ctx context.Context, epoch phase0.Epoch, indices []phase0.ValidatorIndex) ([]*apiv1.ProposerDuty, error) { 133 | provider := beacon.service.(eth2client.ProposerDutiesProvider) 134 | resp, err := provider.ProposerDuties(ctx, &api.ProposerDutiesOpts{ 135 | Epoch: epoch, 136 | Indices: indices, 137 | }) 138 | if err != nil { 139 | return nil, err 140 | } 141 | return resp.Data, err 142 | } 143 | 144 | func (beacon *BeaconChain) GetAttesterDuties(ctx context.Context, epoch phase0.Epoch, indices []phase0.ValidatorIndex) ([]*apiv1.AttesterDuty, error) { 145 | provider := beacon.service.(eth2client.AttesterDutiesProvider) 146 | resp, err := provider.AttesterDuties(ctx, &api.AttesterDutiesOpts{ 147 | Epoch: epoch, 148 | Indices: indices, 149 | }) 150 | if err != nil { 151 | return nil, err 152 | } 153 | return resp.Data, err 154 | } 155 | 156 | func (beacon *BeaconChain) GetBeaconCommitees(ctx context.Context, epoch phase0.Epoch) ([]*apiv1.BeaconCommittee, error) { 157 | provider := beacon.service.(eth2client.BeaconCommitteesProvider) 158 | resp, err := provider.BeaconCommittees(ctx, &api.BeaconCommitteesOpts{ 159 | State: fmt.Sprintf("%d", spec.EpochLowestSlot(epoch)), 160 | Epoch: &epoch, 161 | }) 162 | if err != nil { 163 | return nil, err 164 | } 165 | return resp.Data, err 166 | } 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eth2 Monitor [![Github Actions Status][svg link]][ci link] # 2 | 3 | [svg link]: https://github.com/stakefish/eth2-monitor/actions/workflows/main.yml/badge.svg 4 | [ci link]: https://github.com/stakefish/eth2-monitor/actions/workflows/main.yml 5 | 6 | Eth2 Monitor serves a few purposes: 7 | 8 | * monitors the attestation inclusion distance, 9 | * monitors and alerts slashing events, 10 | 11 | ## Installation ## 12 | 13 | ### Binaries ### 14 | 15 | You can use pre-built binaries from [the latest published release][releases link]. 16 | 17 | [releases link]: https://github.com/stakefish/eth2-monitor/releases 18 | 19 | ### Containers ### 20 | 21 | You can use pre-built containers from Github public container registry. 22 | 23 | ```shell 24 | docker pull ghcr.io/stakefish/eth2-monitor 25 | ``` 26 | 27 | ### Sources ### 28 | 29 | If you choose to build from sources, use this command: 30 | 31 | ```shell 32 | make 33 | ``` 34 | 35 | You will find the executable in `bin/eth2-monitor`. 36 | 37 | It's recommended to use the latest Go version. 38 | 39 | ## Install Beacon Node ## 40 | 41 | `eth2-monitor` relies on [Beacon Node API](https://ethereum.github.io/beacon-APIs/#/) to query the Beacon 42 | Chain and therefore requires a running Beacon Node (any that implements the Beacon API spec should do, e.g. 43 | Prysm, Lighthouse, etc.). 44 | 45 | ## Usage ## 46 | 47 | Most of the commands (except for slashings) require you to provide public keys of validators you want to monitor. 48 | 49 | You can specify the validator public keys in the command line using `-k` option. You can use `-k` multiple times to specify more than one keys. Example: 50 | 51 | ```shell 52 | eth2-monitor cmd -k a1daf19d432507b70fd83214aba105be66c81307d22b3d242cdecaaca5528c1b1e5e3cac5ef7f9e7456e9d202d0ec887 53 | ``` 54 | 55 | You can use a file if you have a bunch of keys. The format is very simple: one public key per line. Example: 56 | 57 | ```shell 58 | echo a1daf19d432507b70fd83214aba105be66c81307d22b3d242cdecaaca5528c1b1e5e3cac5ef7f9e7456e9d202d0ec887 > keys.txt 59 | eth2-monitor cmd keys.txt 60 | ``` 61 | 62 | All public keys are hex-encoded and case-insensitive. On the first run, the monitor will convert the keys into indexes: it takes some time if you have many keys. On the second run, the indexes are loaded from a cache. 63 | 64 | Here's a more involved example invocation: 65 | 66 | ```shell 67 | ./bin/eth2-monitor monitor --beacon-chain-api : --print-successful --log-level trace 68 | ``` 69 | 70 | Don't hesitate to run commands with `--help` to learn more about CLI. 😉 71 | 72 | ### Monitor attestations and proposals ### 73 | 74 | You can monitor the attestation inclusion distance for your validators and alert you if it exceeds a certain threshold. The optimal distance [¹](#footnote-1) is being monitored and by default this is set at 2. You can manually set the distance threshold using `-d`. In order to use the absolute distance, add `--use-absolute-distance`. 75 | 76 | Example: 77 | 78 | ```shell 79 | eth2-monitor monitor -d 1 keys.txt 80 | ``` 81 | 82 | You can forward notification to Slack using `--slack-url`. 83 | 84 | ¹ Optimal distance is the distance between two slots (or blocks) with missed blocks deducted. The optimal distance reflects the best physically possible distance between blocks the attestation is included into the chain. The best optimal distance for an attestation is 0, i.e. the attestation was included at the first possibility. For more information, read [Defining Attestation Effectiveness](https://www.attestant.io/posts/defining-attestation-effectiveness/). 85 | 86 | ### Monitor Vanila Blocks ### 87 | 88 | Optionally, if passed the `eth2-monitor monitor --mev-relays .json [...]` option, eth2-monitor will 89 | inquire given MEV relays after every epoch and if there were any proposals in that epoch, compare proposed 90 | blocks against what MEV relays produced. If it determines there was a missed opportunity in block rewards, 91 | the `totalVanillaBlocks` Prometheus counter will be incremeneted and a log message produced. 92 | 93 | Format of the JSON file is as follows: 94 | ```json 95 | [ 96 | "https://0x8b5d2e73e2a3a55c6c87b8b6eb92e0149a125c852751db1422fa951e42a09b82c142c3ea98d0d9930b056a3bc9896b8f@bloxroute.max-profit.blxrbdn.com", 97 | "https://0x98650451ba02064f7b000f5768cf0cf4d4e492317d82871bdc87ef841a0743f69f0f1eea11168503240ac35d101c9135@mainnet-relay.securerpc.com", 98 | "https://0xa1559ace749633b997cb3fdacffb890aeebdb0f5a3b6aaa7eeeaf1a38af0a8fe88b9e4b1f61f236d2e64d95733327a62@relay.ultrasound.money", 99 | "https://0xa15b52576bcbf1072f4a011c0f99f9fb6c66f3e1ff321f11f461d15e31b1cb359caa092c71bbded0bae5b5ea401aab7e@aestus.live", 100 | "https://0xa7ab7a996c8584251c8f925da3170bdfd6ebc75d50f5ddc4050a6fdc77f2a3b5fce2cc750d0865e05d7228af97d69561@agnostic-relay.net", 101 | "https://0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae@boost-relay.flashbots.net", 102 | "https://0xb0b07cd0abef743db4260b0ed50619cf6ad4d82064cb4fbec9d3ec530f7c5e6793d9f286c4e082c0244ffb9f2658fe88@bloxroute.regulated.blxrbdn.com", 103 | "https://0xb3ee7afcf27f1f1259ac1787876318c6584ee353097a50ed84f51a1f21a323b3736f271a895c7ce918c038e4265918be@relay.edennetwork.io" 104 | ] 105 | ``` 106 | 107 | ### Monitor slashings ### 108 | 109 | You can monitor slashing and send notifications to Slack or Twitter. Use `--slack-url` for Slack, and `--twitter-*` CLI options for Twitter. See `--help` for more details. 110 | 111 | To use the reward, use `--show-reward`. Please, note, it's highly inaccurate, slow and experimental. 112 | 113 | Example: 114 | 115 | ```shell 116 | eth2-monitor slashings --slack-url https://hooks.slack.com/services/YOUR_TOKEN 117 | ``` 118 | 119 | At stakefish, we use it for [our Twitter bot](https://twitter.com/Eth2SlashBot). 120 | 121 | ## Test environment 122 | 123 | Set up a test environment by docker-comppose. 124 | 125 | ```shell 126 | cd test-env 127 | cp env.example .env 128 | cp mev-relays.json.example mev-relays.json 129 | cp validators.txt.example validators.txt 130 | ``` 131 | 132 | Please modify `.env`, `mev-relays.json` and `validators.txt` if needed 133 | 134 | ```shell 135 | docker-compose up 136 | ``` 137 | -------------------------------------------------------------------------------- /pkg/mev.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "eth2-monitor/spec" 7 | "fmt" 8 | "iter" 9 | "math/rand/v2" 10 | "net/http" 11 | "sync" 12 | "time" 13 | 14 | "github.com/attestantio/go-eth2-client/spec/phase0" 15 | "github.com/rs/zerolog/log" 16 | "golang.org/x/sync/errgroup" 17 | ) 18 | 19 | type BidTrace struct { 20 | Slot uint64 `json:"slot,string"` 21 | ParentHash string `json:"parent_hash"` 22 | BlockHash string `json:"block_hash"` 23 | BuilderPubkey string `json:"builder_pubkey"` 24 | ProposerPubkey string `json:"proposer_pubkey"` 25 | ProposerFeeRecipient string `json:"proposer_fee_recipient"` 26 | GasLimit uint64 `json:"gas_limit,string"` 27 | GasUsed uint64 `json:"gas_used,string"` 28 | Value uint64 `json:"value,string"` 29 | } 30 | 31 | /* 32 | Sample response: 33 | [ 34 | 35 | { 36 | "block_hash" : "0x038eacd45f17d198ca1d40a1b9923fd4a93bf17b29c64044ce51fe7725bfb7e6", 37 | "block_number" : "20935934", 38 | "builder_pubkey" : "0xa32aadb23e45595fe4981114a8230128443fd5407d557dc0c158ab93bc2b88939b5a87a84b6863b0d04a4b5a2447f847", 39 | "gas_limit" : "30000000", 40 | "gas_used" : "9700082", 41 | "num_tx" : "150", 42 | "parent_hash" : "0xfe39b1f2c072f60ed0f4f26716f4f20ad792762f4305e1e6aacc9eb0b361033f", 43 | "proposer_fee_recipient" : "0xd4DB3D11394FF2b968bA96ba96deFaf281d69412", 44 | "proposer_pubkey" : "0xb0b5235d72d49e014fe6171ddb9b6668b30e4140a68178501361791064ac690dbba544c35303687856c4abd6e5a1f9e6", 45 | "slot" : "10145666", 46 | "value" : "63557422170996168" 47 | }, 48 | 49 | ] 50 | */ 51 | func requestBidTracesPage(client *http.Client, baseurl string, slot phase0.Slot, limit uint64) ([]BidTrace, error) { 52 | var payloads []BidTrace 53 | 54 | url := fmt.Sprintf("%s/relay/v1/data/bidtraces/proposer_payload_delivered?cursor=%d&limit=%d", baseurl, slot, limit) 55 | log.Debug().Msgf("Calling %v", url) 56 | 57 | resp, err := client.Get(url) 58 | 59 | if err != nil { 60 | log.Error().Msgf("Error retrieving delivered payloads: %v", err) 61 | return nil, err 62 | } 63 | 64 | defer resp.Body.Close() 65 | 66 | err = json.NewDecoder(resp.Body).Decode(&payloads) 67 | 68 | if err != nil { 69 | log.Error().Msgf("Error decoding delivered payloads: %v", err) 70 | return nil, err 71 | } 72 | 73 | // Bid traces should be returned sorted by the slot number in a decreasing order. 74 | for i, _ := range payloads { 75 | if i == 0 { 76 | continue 77 | } 78 | if payloads[i].Slot >= payloads[i-1].Slot { 79 | return nil, fmt.Errorf("Relay returned bid traces in a wrong order: %s", baseurl) 80 | } 81 | } 82 | 83 | return payloads, nil 84 | } 85 | 86 | func requestRelayEpochBidTraces(timeout time.Duration, baseurl string, epoch phase0.Epoch) ([]BidTrace, error) { 87 | var bidtraces []BidTrace 88 | 89 | client := http.Client{ 90 | Timeout: timeout, 91 | } 92 | 93 | epochHighestSlot := spec.EpochHighestSlot(epoch) 94 | epochLowestSlot := spec.EpochLowestSlot(epoch) 95 | 96 | slot := epochHighestSlot 97 | for { 98 | page, err := requestBidTracesPage(&client, baseurl, slot, spec.SLOTS_PER_EPOCH) 99 | if err != nil { 100 | return nil, err 101 | } 102 | if len(page) == 0 { 103 | return nil, fmt.Errorf("Relay returned no bid traces for epoch %v: %s", epoch, baseurl) 104 | } 105 | 106 | for _, trace := range page { 107 | // We're only interested in bid traces from the requested epoch 108 | if trace.Slot >= uint64(epochLowestSlot) && trace.Slot <= uint64(epochHighestSlot) { 109 | bidtraces = append(bidtraces, trace) 110 | } 111 | } 112 | 113 | if page[len(page)-1].Slot <= uint64(epochLowestSlot) { 114 | break 115 | } 116 | 117 | slot -= spec.SLOTS_PER_EPOCH 118 | } 119 | 120 | return bidtraces, nil 121 | } 122 | 123 | func exptBackoff(base time.Duration, maxExponent uint) iter.Seq[time.Duration] { 124 | baseMillis := uint(base / time.Millisecond) 125 | return func(yield func(time.Duration) bool) { 126 | step := base 127 | for { 128 | for _ = range maxExponent + 1 { 129 | jitter := time.Duration(rand.Uint()%baseMillis) * time.Millisecond 130 | delay := step + jitter 131 | if !yield(delay) { 132 | return 133 | } 134 | step *= 2 135 | } 136 | step = base 137 | } 138 | } 139 | } 140 | 141 | func requestEpochBidTraces(ctx context.Context, timeout time.Duration, relays []string, epoch phase0.Epoch) (map[string][]BidTrace, error) { 142 | var mu sync.Mutex 143 | result := make(map[string][]BidTrace) 144 | 145 | ctx, cancel := context.WithTimeout(ctx, timeout) 146 | defer cancel() 147 | 148 | var g errgroup.Group 149 | for _, baseurl := range relays { 150 | g.Go(func() error { 151 | var traces []BidTrace 152 | for delay := range exptBackoff(time.Duration(500)*time.Millisecond, 4) { 153 | var err error 154 | traces, err = requestRelayEpochBidTraces(timeout, baseurl, epoch) 155 | if err == nil { 156 | break 157 | } 158 | log.Error().Msgf("MEV relay request failed: %v", err) 159 | select { 160 | case <-ctx.Done(): 161 | return fmt.Errorf("timeout") 162 | default: 163 | time.Sleep(delay) 164 | } 165 | } 166 | mu.Lock() 167 | defer mu.Unlock() 168 | if _, ok := result[baseurl]; ok { 169 | log.Warn().Msgf("⚠️ Processing the same relay more than once. Check for duplicates on the relays list") 170 | } 171 | result[baseurl] = traces 172 | return nil 173 | }) 174 | } 175 | 176 | return result, g.Wait() 177 | } 178 | 179 | func ListBestBids(ctx context.Context, timeout time.Duration, relays []string, epoch phase0.Epoch, validatorPubkeyFromIndex map[phase0.ValidatorIndex]string, proposals map[phase0.Slot]phase0.ValidatorIndex) (map[phase0.Slot]BidTrace, error) { 180 | bestBids := make(map[phase0.Slot]BidTrace) 181 | 182 | perRelayBidTraces, err := requestEpochBidTraces(ctx, timeout, relays, epoch) 183 | 184 | // Only keep bid traces whose proposer_pubkey matches any of the tracked validators 185 | for _, traces := range perRelayBidTraces { 186 | for _, trace := range traces { 187 | proposerValidatorIndex, ok := proposals[phase0.Slot(trace.Slot)] 188 | if !ok { 189 | continue 190 | } 191 | proposerPubkey := validatorPubkeyFromIndex[proposerValidatorIndex] 192 | if trace.ProposerPubkey != fmt.Sprintf("0x%s", proposerPubkey) { 193 | continue 194 | } 195 | if _, ok := bestBids[phase0.Slot(trace.Slot)]; ok { 196 | if trace.Value > bestBids[phase0.Slot(trace.Slot)].Value { 197 | bestBids[phase0.Slot(trace.Slot)] = trace 198 | } 199 | } else { 200 | bestBids[phase0.Slot(trace.Slot)] = trace 201 | } 202 | } 203 | } 204 | 205 | return bestBids, err 206 | } 207 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2021 stakefish 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/attestantio/go-eth2-client v0.27.1 h1:g7bm+gG/p+gfzYdEuxuAepVWYb8EO+2KojV5/Lo2BxM= 2 | github.com/attestantio/go-eth2-client v0.27.1/go.mod h1:fvULSL9WtNskkOB4i+Yyr6BKpNHXvmpGZj9969fCrfY= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 6 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 8 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/emicklei/dot v1.6.4 h1:cG9ycT67d9Yw22G+mAb4XiuUz6E6H1S0zePp/5Cwe/c= 13 | github.com/emicklei/dot v1.6.4/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= 14 | github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= 15 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 16 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 17 | github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= 18 | github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= 19 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 20 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 21 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 22 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 23 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 24 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 25 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 26 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 27 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 28 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 29 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 30 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 31 | github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= 32 | github.com/go-playground/validator/v10 v10.13.0 h1:cFRQdfaSMCOSfGCCLB20MHvuoHb/s5G8L5pu2ppK5AQ= 33 | github.com/go-playground/validator/v10 v10.13.0/go.mod h1:dwu7+CG8/CtBiJFZDz4e+5Upb6OLw04gtBYw0mcG/z4= 34 | github.com/goccy/go-yaml v1.9.2 h1:2Njwzw+0+pjU2gb805ZC1B/uBuAs2VcZ3K+ZgHwDs7w= 35 | github.com/goccy/go-yaml v1.9.2/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= 36 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 37 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 38 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 39 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 40 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 41 | github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= 42 | github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 43 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 44 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 45 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 46 | github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= 47 | github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= 48 | github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= 49 | github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= 50 | github.com/huandu/go-clone v1.6.0 h1:HMo5uvg4wgfiy5FoGOqlFLQED/VGRm2D9Pi8g1FXPGc= 51 | github.com/huandu/go-clone v1.6.0/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= 52 | github.com/huandu/go-clone/generic v1.6.0 h1:Wgmt/fUZ28r16F2Y3APotFD59sHk1p78K0XLdbUYN5U= 53 | github.com/huandu/go-clone/generic v1.6.0/go.mod h1:xgd9ZebcMsBWWcBx5mVMCoqMX24gLWr5lQicr+nVXNs= 54 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 55 | github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= 56 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 57 | github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= 58 | github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= 59 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 60 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 61 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 62 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 63 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 64 | github.com/leodido/go-urn v1.2.3 h1:6BE2vPT0lqoz3fmOesHZiaiFh7889ssCo2GMvLCfiuA= 65 | github.com/leodido/go-urn v1.2.3/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 66 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 67 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 68 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 69 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 70 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 71 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 72 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 73 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 74 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 75 | github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= 76 | github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= 77 | github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 78 | github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 79 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 80 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 81 | github.com/pk910/dynamic-ssz v0.0.4 h1:DT29+1055tCEPCaR4V/ez+MOKW7BzBsmjyFvBRqx0ME= 82 | github.com/pk910/dynamic-ssz v0.0.4/go.mod h1:b6CrLaB2X7pYA+OSEEbkgXDEcRnjLOZIxZTsMuO/Y9c= 83 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 84 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 85 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 86 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 87 | github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= 88 | github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= 89 | github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= 90 | github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= 91 | github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= 92 | github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= 93 | github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= 94 | github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= 95 | github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 h1:lC8kiphgdOBTcbTvo8MwkvpKjO0SlAgjv4xIK5FGJ94= 96 | github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15/go.mod h1:8svFBIKKu31YriBG/pNizo9N0Jr9i5PQ+dFkxWg3x5k= 97 | github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= 98 | github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= 99 | github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= 100 | github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= 101 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 102 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 103 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 104 | github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= 105 | github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 106 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 107 | github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= 108 | github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= 109 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 110 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 111 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 112 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 113 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 114 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 115 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 116 | go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= 117 | go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= 118 | go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo= 119 | go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4= 120 | go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= 121 | go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= 122 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 123 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 124 | golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 125 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 126 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 127 | golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 128 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 129 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 130 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 131 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 132 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 133 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 134 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 135 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 136 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 137 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 138 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 139 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 140 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 141 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 142 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 143 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 144 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 145 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 146 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 147 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 148 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 149 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 150 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 151 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 152 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 153 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 154 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 155 | gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= 156 | gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= 157 | gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= 158 | gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= 159 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 160 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 161 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 162 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 163 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 164 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 165 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 166 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 167 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 168 | -------------------------------------------------------------------------------- /pkg/monitoring.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "maps" 8 | "os" 9 | "slices" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "eth2-monitor/beaconchain" 15 | "eth2-monitor/cmd/opts" 16 | "eth2-monitor/spec" 17 | 18 | eth2client "github.com/attestantio/go-eth2-client" 19 | "github.com/attestantio/go-eth2-client/api" 20 | v1 "github.com/attestantio/go-eth2-client/api/v1" 21 | "github.com/attestantio/go-eth2-client/spec/electra" 22 | "github.com/attestantio/go-eth2-client/spec/phase0" 23 | "github.com/pkg/errors" 24 | "github.com/rs/zerolog/log" 25 | 26 | "github.com/prometheus/client_golang/prometheus" 27 | ) 28 | 29 | const VALIDATOR_INDEX_INVALID = ^phase0.ValidatorIndex(0) 30 | 31 | // ResolveValidatorKeys transforms validator public keys into their indexes. 32 | // It returns direct and reversed mapping. 33 | func ResolveValidatorKeys(ctx context.Context, beacon *beaconchain.BeaconChain, plainPubKeys []string, epoch phase0.Epoch) (map[phase0.ValidatorIndex]string, error) { 34 | normalized := make([]string, len(plainPubKeys)) 35 | for i, key := range plainPubKeys { 36 | normalized[i] = beaconchain.NormalizedPublicKey(key) 37 | } 38 | 39 | result := make(map[phase0.ValidatorIndex]string) 40 | 41 | // Resolve cached validators to indexes 42 | cache := LoadCache() 43 | uncached := []string{} 44 | for _, pubkey := range normalized { 45 | if cachedIndex, ok := cache.Validators[pubkey]; ok && time.Until(cachedIndex.At) < 8*time.Hour { 46 | if cachedIndex.Index != VALIDATOR_INDEX_INVALID { 47 | result[cachedIndex.Index] = pubkey 48 | } 49 | } else { 50 | uncached = append(uncached, pubkey) 51 | } 52 | } 53 | 54 | // Resolve validators not in cache 55 | for chunk := range slices.Chunk(uncached, 100) { 56 | partial, err := beacon.GetValidatorIndexes(ctx, chunk, epoch) 57 | if err != nil { 58 | return nil, errors.Wrap(err, "Could not retrieve validator indexes") 59 | } 60 | for _, pubkey := range chunk { 61 | if index, ok := partial[pubkey]; ok { 62 | result[index] = pubkey 63 | cache.Validators[pubkey] = CachedIndex{ 64 | Index: index, 65 | At: time.Now(), 66 | } 67 | } else { 68 | cache.Validators[pubkey] = CachedIndex{ 69 | Index: VALIDATOR_INDEX_INVALID, 70 | At: time.Now(), 71 | } 72 | } 73 | } 74 | } 75 | 76 | SaveCache(cache) 77 | 78 | return result, nil 79 | } 80 | 81 | func ListCommittees(ctx context.Context, beacon *beaconchain.BeaconChain, start phase0.Epoch, end phase0.Epoch) (map[phase0.Slot]map[phase0.CommitteeIndex][]phase0.ValidatorIndex, error) { 82 | result := make(map[phase0.Slot]map[phase0.CommitteeIndex][]phase0.ValidatorIndex) 83 | 84 | for epoch := start; epoch <= end; epoch++ { 85 | resp, err := beacon.GetBeaconCommitees(ctx, phase0.Epoch(epoch)) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | for _, committee := range resp { 91 | if _, ok := result[committee.Slot]; !ok { 92 | result[committee.Slot] = make(map[phase0.CommitteeIndex][]phase0.ValidatorIndex) 93 | } 94 | result[committee.Slot][committee.Index] = committee.Validators 95 | } 96 | } 97 | 98 | return result, nil 99 | } 100 | 101 | // ListProposerDuties returns block proposers scheduled for epoch. 102 | // To improve performance, it has to narrow the set of validators for which it checks duties. 103 | func ListProposerDuties(ctx context.Context, beacon *beaconchain.BeaconChain, epoch phase0.Epoch, validators []phase0.ValidatorIndex) (map[phase0.Slot]phase0.ValidatorIndex, error) { 104 | result := make(map[phase0.Slot]phase0.ValidatorIndex) 105 | for chunk := range slices.Chunk(validators, 250) { 106 | duties, err := beacon.GetProposerDuties(ctx, epoch, chunk) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | for _, duty := range duties { 112 | result[duty.Slot] = phase0.ValidatorIndex(duty.ValidatorIndex) 113 | } 114 | } 115 | return result, nil 116 | } 117 | 118 | func ListAttesterDuties(ctx context.Context, beacon *beaconchain.BeaconChain, epoch phase0.Epoch, validators []phase0.ValidatorIndex) (map[phase0.Slot]Set[phase0.ValidatorIndex], error) { 119 | duties, err := beacon.GetAttesterDuties(ctx, epoch, validators) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | result := make(map[phase0.Slot]Set[phase0.ValidatorIndex]) 125 | for _, duty := range duties { 126 | slot := duty.Slot 127 | if _, ok := result[slot]; !ok { 128 | result[slot] = NewSet[phase0.ValidatorIndex]() 129 | } 130 | result[slot].Add(duty.ValidatorIndex) 131 | } 132 | return result, nil 133 | } 134 | 135 | func ListEpochBlocks(ctx context.Context, beacon *beaconchain.BeaconChain, epoch phase0.Epoch) (map[phase0.Slot]*electra.SignedBeaconBlock, error) { 136 | result := make(map[phase0.Slot]*electra.SignedBeaconBlock, spec.SLOTS_PER_EPOCH) 137 | low := spec.EpochLowestSlot(epoch) 138 | high := spec.EpochHighestSlot(epoch) 139 | for slot := low; slot <= high; slot++ { 140 | block, err := beacon.GetBlock(ctx, phase0.Slot(slot)) 141 | 142 | if err != nil { 143 | log.Error().Err(err) 144 | continue 145 | } 146 | 147 | if block == nil { 148 | // Missed slot 149 | continue 150 | } 151 | 152 | result[slot] = block 153 | } 154 | return result, nil 155 | } 156 | 157 | // SubscribeToEpochs subscribes to changings of the beacon chain head. 158 | // Note, if --replay-epoch or --since-epoch options passed, SubscribeToEpochs will not 159 | // listen to real-time changes. 160 | func SubscribeToEpochs(ctx context.Context, beacon *beaconchain.BeaconChain, wg *sync.WaitGroup, epochsChan chan phase0.Epoch) { 161 | defer wg.Done() 162 | 163 | finalityProvider := beacon.Service().(eth2client.FinalityProvider) 164 | resp, err := finalityProvider.Finality(ctx, &api.FinalityOpts{State: "head"}) 165 | Must(err) 166 | 167 | lastEpoch := resp.Data.Justified.Epoch 168 | 169 | if len(opts.Monitor.ReplayEpoch) > 0 { 170 | for _, epoch := range opts.Monitor.ReplayEpoch { 171 | epochsChan <- phase0.Epoch(epoch) 172 | } 173 | close(epochsChan) 174 | return 175 | } 176 | if opts.Monitor.SinceEpoch != ^uint64(0) { 177 | for epoch := opts.Monitor.SinceEpoch; phase0.Epoch(epoch) < lastEpoch; epoch++ { 178 | epochsChan <- phase0.Epoch(epoch) 179 | } 180 | close(epochsChan) 181 | return 182 | } 183 | 184 | eventsHandlerFunc := func(event *v1.Event) { 185 | headEvent := event.Data.(*v1.HeadEvent) 186 | log.Trace().Msgf("New head slot %v block %v", headEvent.Slot, headEvent.Block.String()) 187 | thisEpoch := spec.EpochFromSlot(headEvent.Slot) 188 | if thisEpoch > lastEpoch { 189 | log.Trace().Msgf("New epoch %v at slot %v", thisEpoch, headEvent.Slot) 190 | epochsChan <- phase0.Epoch(lastEpoch) // send the epoch that has just ended 191 | lastEpoch = thisEpoch 192 | } 193 | } 194 | 195 | eventsProvider := beacon.Service().(eth2client.EventsProvider) 196 | err = eventsProvider.Events(ctx, &api.EventsOpts{ 197 | Topics: []string{"head"}, 198 | Handler: eventsHandlerFunc, 199 | }) 200 | Must(err) 201 | } 202 | 203 | func LoadKeys(pubkeysFiles []string) ([]string, error) { 204 | plainKeys := opts.Monitor.Pubkeys[:] 205 | for _, fname := range pubkeysFiles { 206 | file, err := os.Open(fname) 207 | if err != nil { 208 | return nil, err 209 | } 210 | defer file.Close() 211 | 212 | scanner := bufio.NewScanner(file) 213 | for scanner.Scan() { 214 | line := strings.TrimSpace(scanner.Text()) 215 | if len(line) == 0 { 216 | continue 217 | } 218 | plainKeys = append(plainKeys, line) 219 | } 220 | 221 | err = scanner.Err() 222 | if err != nil { 223 | return nil, err 224 | } 225 | } 226 | 227 | return plainKeys, nil 228 | } 229 | 230 | func LoadMEVRelays(mevRelaysFilePath string) ([]string, error) { 231 | relays := []string{} 232 | 233 | contents, err := os.ReadFile(mevRelaysFilePath) 234 | if err != nil { 235 | return nil, err 236 | } 237 | 238 | err = json.Unmarshal(contents, &relays) 239 | if err != nil { 240 | return nil, err 241 | } 242 | 243 | return relays, nil 244 | } 245 | 246 | // MonitorAttestationsAndProposals listens to the beacon chain head changes and checks new blocks and attestations. 247 | func MonitorAttestationsAndProposals(ctx context.Context, beacon *beaconchain.BeaconChain, plainKeys []string, mevRelays []string, wg *sync.WaitGroup, epochsChan chan phase0.Epoch) { 248 | defer wg.Done() 249 | 250 | epochGauge := prometheus.NewGauge( 251 | prometheus.GaugeOpts{ 252 | Namespace: "ETH2", 253 | Name: "epoch", 254 | Help: "Current justified epoch", 255 | }) 256 | prometheus.MustRegister(epochGauge) 257 | 258 | lastProposedEmptyBlockSlotGauge := prometheus.NewGauge( 259 | prometheus.GaugeOpts{ 260 | Namespace: "ETH2", 261 | Name: "lastProposedEmptyBlockSlot", 262 | Help: "Slot of the last proposed block containing no transactions", 263 | }) 264 | prometheus.MustRegister(lastProposedEmptyBlockSlotGauge) 265 | 266 | totalMissedProposalsCounter := prometheus.NewCounter( 267 | prometheus.CounterOpts{ 268 | Namespace: "ETH2", 269 | Name: "totalMissedProposals", 270 | Help: "Proposals missed since monitoring started", 271 | }) 272 | prometheus.MustRegister(totalMissedProposalsCounter) 273 | 274 | lastMissedProposalSlotGauge := prometheus.NewGauge( 275 | prometheus.GaugeOpts{ 276 | Namespace: "ETH2", 277 | Name: "lastMissedProposalSlot", 278 | Help: "Slot of the last missed proposal", 279 | }) 280 | prometheus.MustRegister(lastMissedProposalSlotGauge) 281 | 282 | lastMissedProposalValidatorIndexGauge := prometheus.NewGauge( 283 | prometheus.GaugeOpts{ 284 | Namespace: "ETH2", 285 | Name: "lastMissedProposalValidatorIndex", 286 | Help: "Validator index of the last missed proposal", 287 | }) 288 | prometheus.MustRegister(lastMissedProposalValidatorIndexGauge) 289 | 290 | totalCanonicalProposalsCounter := prometheus.NewCounter( 291 | prometheus.CounterOpts{ 292 | Namespace: "ETH2", 293 | Name: "totalServedProposals", 294 | Help: "Canonical proposals since monitoring started", 295 | }) 296 | prometheus.MustRegister(totalCanonicalProposalsCounter) 297 | 298 | totalMissedAttestationsCounter := prometheus.NewCounter( 299 | prometheus.CounterOpts{ 300 | Namespace: "ETH2", 301 | Name: "totalMissedAttestations", 302 | Help: "Attestations missed since monitoring started", 303 | }) 304 | prometheus.MustRegister(totalMissedAttestationsCounter) 305 | 306 | totalProposedEmptyBlocksCounter := prometheus.NewCounter( 307 | prometheus.CounterOpts{ 308 | Namespace: "ETH2", 309 | Name: "totalProposedEmptyBlocks", 310 | Help: "Proposed blocks containing no transactions", 311 | }) 312 | prometheus.MustRegister(totalProposedEmptyBlocksCounter) 313 | 314 | totalVanillaBlocksCounter := prometheus.NewCounter( 315 | prometheus.CounterOpts{ 316 | Namespace: "ETH2", 317 | Name: "totalVanillaBlocks", 318 | Help: "Proposed blocks not matching those built by MEV relays", 319 | }) 320 | prometheus.MustRegister(totalVanillaBlocksCounter) 321 | 322 | lastVanillaBlockSlotGauge := prometheus.NewGauge( 323 | prometheus.GaugeOpts{ 324 | Namespace: "ETH2", 325 | Name: "lastVanillaBlockSlot", 326 | Help: "Slot of the last proposed vanilla block", 327 | }) 328 | prometheus.MustRegister(lastVanillaBlockSlotGauge) 329 | 330 | lastVanillaBlockValidatorGauge := prometheus.NewGauge( 331 | prometheus.GaugeOpts{ 332 | Namespace: "ETH2", 333 | Name: "lastVanillaBlockValidator", 334 | Help: "Index of the last validator that proposed a vanilla block", 335 | }) 336 | prometheus.MustRegister(lastVanillaBlockValidatorGauge) 337 | 338 | totalCanonicalAttestationsCounter := prometheus.NewCounter( 339 | prometheus.CounterOpts{ 340 | Namespace: "ETH2", 341 | // TODO(deni): Rename to totalCanonicalAttestations 342 | Name: "totalServedAttestations", 343 | Help: "Canonical attestations since monitoring started", 344 | }) 345 | prometheus.MustRegister(totalCanonicalAttestationsCounter) 346 | 347 | totalDelayedAttestationsOverToleranceCounter := prometheus.NewCounter( 348 | prometheus.CounterOpts{ 349 | Namespace: "ETH2", 350 | Name: "totalDelayedAttestationsOverTolerance", 351 | Help: "Attestation delayed over tolerance distance setting since monitoring started", 352 | }) 353 | prometheus.MustRegister(totalDelayedAttestationsOverToleranceCounter) 354 | 355 | // https://www.attestant.io/posts/defining-attestation-effectiveness/ 356 | canonicalAttestationDistances := prometheus.NewHistogram(prometheus.HistogramOpts{ 357 | Namespace: "ETH2", 358 | Name: "canonicalAttestationDistances", 359 | Help: "Histogram of canonical attestation distances.", 360 | Buckets: prometheus.LinearBuckets(1, 1, 32), 361 | }) 362 | prometheus.MustRegister(canonicalAttestationDistances) 363 | 364 | unfulfilledAttesterDuties := make(map[phase0.Slot]Set[phase0.ValidatorIndex]) 365 | committees := make(map[phase0.Slot]map[phase0.CommitteeIndex][]phase0.ValidatorIndex) 366 | for epoch := range epochsChan { 367 | log.Debug().Msgf("New epoch %v", epoch) 368 | epochGauge.Set(float64(epoch)) 369 | 370 | var validatorPubkeyFromIndex map[phase0.ValidatorIndex]string 371 | Measure(func() { 372 | var err error 373 | validatorPubkeyFromIndex, err = ResolveValidatorKeys(ctx, beacon, plainKeys, epoch) 374 | Must(err) 375 | }, "ResolveValidatorKeys(epoch=%v)", epoch) 376 | 377 | if len(validatorPubkeyFromIndex) == 0 { 378 | panic("No active validators") 379 | } 380 | log.Debug().Msgf("Epoch %v validators: %v/%v", epoch, len(validatorPubkeyFromIndex), len(plainKeys)) 381 | 382 | Measure(func() { 383 | var err error 384 | committees, err = ListCommittees(ctx, beacon, phase0.Epoch(epoch-1), phase0.Epoch(epoch)) 385 | Must(err) 386 | }, "ListCommittees(epoch=%v)", epoch) 387 | Measure(func() { 388 | epochAttesterDuties, err := ListAttesterDuties(ctx, beacon, phase0.Epoch(epoch), slices.Collect(maps.Keys(validatorPubkeyFromIndex))) 389 | Must(err) 390 | for slot, attesters := range epochAttesterDuties { 391 | unfulfilledAttesterDuties[slot] = attesters 392 | } 393 | }, "ListAttesterDuties(epoch=%v)", epoch) 394 | 395 | var unfulfilledProposerDuties map[phase0.Slot]phase0.ValidatorIndex 396 | Measure(func() { 397 | var err error 398 | unfulfilledProposerDuties, err = ListProposerDuties(ctx, beacon, phase0.Epoch(epoch), slices.Collect(maps.Keys(validatorPubkeyFromIndex))) 399 | Must(err) 400 | }, "ListProposerDuties(epoch=%v)", epoch) 401 | 402 | var bestBids map[phase0.Slot]BidTrace 403 | if len(mevRelays) > 0 { 404 | Measure(func() { 405 | var err error 406 | bestBids, err = ListBestBids(ctx, 4*time.Second, mevRelays, epoch, validatorPubkeyFromIndex, unfulfilledProposerDuties) 407 | if err != nil { 408 | log.Error().Stack().Err(err) 409 | // Even if RequestEpochBidTraces() returned an error, there may still be valuable partial results in bidtraces, so process them! 410 | } 411 | }, "ListBestBids(epoch=%v)", epoch) 412 | log.Debug().Msgf("Number of MEV boosts is %v", len(bestBids)) 413 | } 414 | 415 | var epochBlocks map[phase0.Slot]*electra.SignedBeaconBlock 416 | Measure(func() { 417 | var err error 418 | epochBlocks, err = ListEpochBlocks(ctx, beacon, phase0.Epoch(epoch)) 419 | Must(err) 420 | }, "ListEpochBlocks(epoch=%v)", epoch) 421 | 422 | // https://eips.ethereum.org/EIPS/eip-7549 423 | for _, block := range epochBlocks { 424 | for _, attestation := range block.Message.Body.Attestations { 425 | attesters := NewSet[phase0.ValidatorIndex]() 426 | 427 | committeesLen := 0 428 | for _, committeeIndex := range attestation.CommitteeBits.BitIndices() { 429 | committeesLen += len(committees[attestation.Data.Slot][phase0.CommitteeIndex(committeeIndex)]) 430 | } 431 | if attestation.AggregationBits.Len() != uint64(committeesLen) { 432 | log.Error().Msgf("Sanity check violation: AggregationBits length mismatch: computed=%v actual=%v", committeesLen, attestation.AggregationBits.Len()) 433 | } 434 | 435 | // https://github.com/ethereum/consensus-specs/blob/8410e4fa376b74f550d5981f4c42d6593401046c/specs/electra/beacon-chain.md#new-get_committee_indices 436 | committeeOffset := 0 437 | for _, committeeIndex := range attestation.CommitteeBits.BitIndices() { 438 | // https://github.com/ethereum/consensus-specs/blob/8410e4fa376b74f550d5981f4c42d6593401046c/specs/electra/beacon-chain.md#modified-get_attesting_indices 439 | committee := committees[attestation.Data.Slot][phase0.CommitteeIndex(committeeIndex)] 440 | for i, validatorCommitteeIndex := range committee { 441 | if attestation.AggregationBits.BitAt(uint64(committeeOffset + i)) { 442 | if _, ok := validatorPubkeyFromIndex[validatorCommitteeIndex]; ok { 443 | attesters.Add(validatorCommitteeIndex) 444 | } 445 | } 446 | } 447 | committeeOffset += len(committee) 448 | } 449 | 450 | attestedSlot := attestation.Data.Slot 451 | for validatorIndex := range attesters { 452 | if _, ok := validatorPubkeyFromIndex[validatorIndex]; !ok { 453 | continue 454 | } 455 | 456 | unfulfilledAttesterDuties[attestedSlot].Remove(validatorIndex) 457 | if unfulfilledAttesterDuties[attestedSlot].IsEmpty() { 458 | delete(unfulfilledAttesterDuties, attestedSlot) 459 | } 460 | 461 | // https://www.attestant.io/posts/defining-attestation-effectiveness/ 462 | earliestInclusionSlot := attestedSlot + 1 463 | attestationDistance := block.Message.Slot - phase0.Slot(earliestInclusionSlot) 464 | // Do not penalize validator for skipped slots 465 | for s := earliestInclusionSlot; s < block.Message.Slot; s++ { 466 | if _, ok := epochBlocks[phase0.Slot(s)]; !ok { 467 | attestationDistance-- 468 | } 469 | } 470 | 471 | if attestationDistance > 2 { 472 | Report("⚠️ 🧾 Validator %v (%v) attested slot %v at slot %v, epoch %v, attestation distance is %v", 473 | validatorIndex, validatorPubkeyFromIndex[validatorIndex], attestedSlot, block.Message.Slot, epoch, attestationDistance) 474 | totalDelayedAttestationsOverToleranceCounter.Inc() 475 | } else if opts.Monitor.PrintSuccessful { 476 | Info("✅ 🧾 Validator %v (%v) attested slot %v at slot %v, epoch %v", validatorIndex, validatorPubkeyFromIndex[validatorIndex], attestedSlot, block.Message.Slot, epoch) 477 | } 478 | 479 | totalCanonicalAttestationsCounter.Inc() 480 | canonicalAttestationDistances.Observe(float64(attestationDistance)) 481 | } 482 | } 483 | } 484 | 485 | // Attestation is assumed to be missed if it was not included within 486 | // current epoch or one after the current. Normally, attestations should 487 | // land in 1-2 *slots* after the attested one. 488 | missedAttestationEpoch := epoch - 1 489 | missedAttestationSlotHigh := spec.EpochHighestSlot(missedAttestationEpoch) 490 | log.Debug().Msgf("Unfulfilled attester duties at the end of epoch %v (map[SLOT]{VALIDATOR_INDEX...}): %v", epoch, unfulfilledAttesterDuties) 491 | for _, slot := range slices.Sorted(maps.Keys(unfulfilledAttesterDuties)) { 492 | if slot > missedAttestationSlotHigh { 493 | break 494 | } 495 | for validatorIndex := range unfulfilledAttesterDuties[slot].Elems() { 496 | Report("❌ 🧾 Validator %v (%v) did not attest slot %v (epoch %v)", validatorIndex, validatorPubkeyFromIndex[validatorIndex], slot, epoch) 497 | totalMissedAttestationsCounter.Inc() 498 | } 499 | delete(unfulfilledAttesterDuties, slot) 500 | } 501 | 502 | log.Trace().Msgf("Epoch %v proposer duties: %v", epoch, unfulfilledProposerDuties) 503 | for slot, block := range epochBlocks { 504 | validatorIndex, ok := unfulfilledProposerDuties[slot] 505 | if !ok { 506 | continue 507 | } 508 | if block.Message.ProposerIndex != validatorIndex { 509 | log.Error().Msgf("Block proposed by an unexpected validator") 510 | continue 511 | } 512 | 513 | totalCanonicalProposalsCounter.Inc() 514 | delete(unfulfilledProposerDuties, slot) 515 | 516 | if len(block.Message.Body.ExecutionPayload.Transactions) == 0 { 517 | validatorPublicKey := validatorPubkeyFromIndex[validatorIndex] 518 | Report("⚠️ 🧱 Validator %v (%v) proposed a block containing no transactions at epoch %v and slot %v", validatorPublicKey, validatorIndex, epoch, slot) 519 | lastProposedEmptyBlockSlotGauge.Set(float64(slot)) 520 | totalProposedEmptyBlocksCounter.Inc() 521 | } 522 | 523 | if len(mevRelays) > 0 { 524 | execution_block_hash := block.Message.Body.ExecutionPayload.BlockHash 525 | trace, ok := bestBids[slot] 526 | if !ok { 527 | totalVanillaBlocksCounter.Inc() 528 | lastVanillaBlockSlotGauge.Set(float64(slot)) 529 | lastVanillaBlockValidatorGauge.Set(float64(validatorIndex)) 530 | log.Error().Msgf("Missing bid trace for proposal slot %v, validator %v (%v)", slot, validatorIndex, validatorPubkeyFromIndex[validatorIndex]) 531 | continue 532 | } 533 | if execution_block_hash.String() != trace.BlockHash { 534 | totalVanillaBlocksCounter.Inc() 535 | lastVanillaBlockSlotGauge.Set(float64(slot)) 536 | lastVanillaBlockValidatorGauge.Set(float64(validatorIndex)) 537 | log.Error().Msgf("Validator %v (%v) proposed a vanilla block %v at slot %v", validatorIndex, validatorPubkeyFromIndex[validatorIndex], execution_block_hash, slot) 538 | continue 539 | } 540 | if opts.Monitor.PrintSuccessful { 541 | // Our validator proposed the best block -- all good 542 | Info("✅ 🧾 Validator %v (%v) proposed optimal MEV execution block %v at slot %v, epoch %v", validatorIndex, validatorPubkeyFromIndex[validatorIndex], trace.BlockHash, slot, epoch) 543 | } 544 | } 545 | } 546 | for slot, validatorIndex := range unfulfilledProposerDuties { 547 | Report("❌ 🧱 Validator %v missed proposal at slot %v", validatorIndex, slot) 548 | totalMissedProposalsCounter.Inc() 549 | lastMissedProposalSlotGauge.Set(float64(slot)) 550 | lastMissedProposalValidatorIndexGauge.Set(float64(validatorIndex)) 551 | } 552 | } 553 | } 554 | -------------------------------------------------------------------------------- /test-env/grafana/dashboards/eth2-monitor.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "datasource", 8 | "uid": "grafana" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "target": { 15 | "limit": 100, 16 | "matchAny": false, 17 | "tags": [], 18 | "type": "dashboard" 19 | }, 20 | "type": "dashboard" 21 | } 22 | ] 23 | }, 24 | "editable": true, 25 | "fiscalYearStartMonth": 0, 26 | "graphTooltip": 0, 27 | "id": 1, 28 | "links": [], 29 | "panels": [ 30 | { 31 | "datasource": { 32 | "type": "prometheus", 33 | "uid": "PBFA97CFB590B2093" 34 | }, 35 | "fieldConfig": { 36 | "defaults": { 37 | "color": { 38 | "mode": "continuous-GrYlRd" 39 | }, 40 | "custom": { 41 | "axisBorderShow": false, 42 | "axisCenteredZero": false, 43 | "axisColorMode": "text", 44 | "axisGridShow": true, 45 | "axisLabel": "", 46 | "axisPlacement": "auto", 47 | "axisSoftMin": 0, 48 | "fillOpacity": 80, 49 | "gradientMode": "scheme", 50 | "hideFrom": { 51 | "legend": false, 52 | "tooltip": false, 53 | "viz": false 54 | }, 55 | "lineWidth": 1, 56 | "scaleDistribution": { 57 | "type": "linear" 58 | }, 59 | "thresholdsStyle": { 60 | "mode": "off" 61 | } 62 | }, 63 | "mappings": [], 64 | "min": 0, 65 | "thresholds": { 66 | "mode": "absolute", 67 | "steps": [ 68 | { 69 | "color": "green", 70 | "value": 0 71 | }, 72 | { 73 | "color": "red", 74 | "value": 80 75 | } 76 | ] 77 | }, 78 | "unit": "none" 79 | }, 80 | "overrides": [] 81 | }, 82 | "gridPos": { 83 | "h": 8, 84 | "w": 12, 85 | "x": 0, 86 | "y": 0 87 | }, 88 | "id": 19, 89 | "options": { 90 | "barRadius": 0, 91 | "barWidth": 0.97, 92 | "fullHighlight": false, 93 | "groupWidth": 0.7, 94 | "legend": { 95 | "calcs": [], 96 | "displayMode": "list", 97 | "placement": "bottom", 98 | "showLegend": true 99 | }, 100 | "orientation": "vertical", 101 | "showValue": "always", 102 | "stacking": "none", 103 | "tooltip": { 104 | "hideZeros": false, 105 | "mode": "multi", 106 | "sort": "desc" 107 | }, 108 | "xField": "Time", 109 | "xTickLabelRotation": 0, 110 | "xTickLabelSpacing": 0 111 | }, 112 | "pluginVersion": "12.2.1", 113 | "targets": [ 114 | { 115 | "datasource": { 116 | "type": "prometheus", 117 | "uid": "prometheus" 118 | }, 119 | "editorMode": "code", 120 | "exemplar": false, 121 | "expr": "histogram_quantile(0.99, sum(rate(ETH2_canonicalAttestationDistances_bucket[$__range])) by (service, le))", 122 | "format": "time_series", 123 | "instant": true, 124 | "interval": "", 125 | "legendFormat": "{{service}}", 126 | "range": false, 127 | "refId": "A" 128 | } 129 | ], 130 | "title": "Canonical Attestation Distances (99%)", 131 | "type": "barchart" 132 | }, 133 | { 134 | "datasource": { 135 | "type": "prometheus", 136 | "uid": "PBFA97CFB590B2093" 137 | }, 138 | "fieldConfig": { 139 | "defaults": { 140 | "color": { 141 | "mode": "palette-classic" 142 | }, 143 | "custom": { 144 | "axisBorderShow": false, 145 | "axisCenteredZero": false, 146 | "axisColorMode": "text", 147 | "axisLabel": "", 148 | "axisPlacement": "auto", 149 | "barAlignment": 0, 150 | "barWidthFactor": 0.6, 151 | "drawStyle": "line", 152 | "fillOpacity": 0, 153 | "gradientMode": "none", 154 | "hideFrom": { 155 | "legend": false, 156 | "tooltip": false, 157 | "viz": false 158 | }, 159 | "insertNulls": false, 160 | "lineInterpolation": "linear", 161 | "lineWidth": 1, 162 | "pointSize": 5, 163 | "scaleDistribution": { 164 | "type": "linear" 165 | }, 166 | "showPoints": "auto", 167 | "showValues": false, 168 | "spanNulls": false, 169 | "stacking": { 170 | "group": "A", 171 | "mode": "none" 172 | }, 173 | "thresholdsStyle": { 174 | "mode": "off" 175 | } 176 | }, 177 | "mappings": [], 178 | "thresholds": { 179 | "mode": "absolute", 180 | "steps": [ 181 | { 182 | "color": "green", 183 | "value": 0 184 | }, 185 | { 186 | "color": "red", 187 | "value": 80 188 | } 189 | ] 190 | }, 191 | "unit": "percentunit" 192 | }, 193 | "overrides": [] 194 | }, 195 | "gridPos": { 196 | "h": 8, 197 | "w": 12, 198 | "x": 12, 199 | "y": 0 200 | }, 201 | "id": 8, 202 | "options": { 203 | "legend": { 204 | "calcs": [], 205 | "displayMode": "list", 206 | "placement": "bottom", 207 | "showLegend": true 208 | }, 209 | "tooltip": { 210 | "hideZeros": false, 211 | "mode": "multi", 212 | "sort": "desc" 213 | } 214 | }, 215 | "pluginVersion": "12.2.1", 216 | "targets": [ 217 | { 218 | "datasource": { 219 | "type": "prometheus", 220 | "uid": "prometheus" 221 | }, 222 | "editorMode": "code", 223 | "exemplar": true, 224 | "expr": "max(rate(ETH2_totalMissedAttestations[10m]) / rate(ETH2_totalServedAttestations[10m])) by (service)", 225 | "interval": "", 226 | "legendFormat": "{{service}}", 227 | "range": true, 228 | "refId": "A" 229 | } 230 | ], 231 | "title": "Percentage Missed Attestations", 232 | "type": "timeseries" 233 | }, 234 | { 235 | "datasource": { 236 | "type": "prometheus", 237 | "uid": "PBFA97CFB590B2093" 238 | }, 239 | "fieldConfig": { 240 | "defaults": { 241 | "color": { 242 | "mode": "palette-classic" 243 | }, 244 | "custom": { 245 | "axisBorderShow": false, 246 | "axisCenteredZero": false, 247 | "axisColorMode": "text", 248 | "axisLabel": "", 249 | "axisPlacement": "auto", 250 | "barAlignment": 0, 251 | "barWidthFactor": 0.6, 252 | "drawStyle": "line", 253 | "fillOpacity": 0, 254 | "gradientMode": "none", 255 | "hideFrom": { 256 | "legend": false, 257 | "tooltip": false, 258 | "viz": false 259 | }, 260 | "insertNulls": false, 261 | "lineInterpolation": "linear", 262 | "lineWidth": 1, 263 | "pointSize": 5, 264 | "scaleDistribution": { 265 | "type": "linear" 266 | }, 267 | "showPoints": "auto", 268 | "showValues": false, 269 | "spanNulls": false, 270 | "stacking": { 271 | "group": "A", 272 | "mode": "none" 273 | }, 274 | "thresholdsStyle": { 275 | "mode": "off" 276 | } 277 | }, 278 | "mappings": [], 279 | "thresholds": { 280 | "mode": "absolute", 281 | "steps": [ 282 | { 283 | "color": "green", 284 | "value": 0 285 | }, 286 | { 287 | "color": "red", 288 | "value": 80 289 | } 290 | ] 291 | }, 292 | "unit": "percentunit" 293 | }, 294 | "overrides": [] 295 | }, 296 | "gridPos": { 297 | "h": 8, 298 | "w": 12, 299 | "x": 0, 300 | "y": 8 301 | }, 302 | "id": 10, 303 | "options": { 304 | "legend": { 305 | "calcs": [], 306 | "displayMode": "list", 307 | "placement": "bottom", 308 | "showLegend": true 309 | }, 310 | "tooltip": { 311 | "hideZeros": false, 312 | "mode": "multi", 313 | "sort": "desc" 314 | } 315 | }, 316 | "pluginVersion": "12.2.1", 317 | "targets": [ 318 | { 319 | "datasource": { 320 | "type": "prometheus", 321 | "uid": "prometheus" 322 | }, 323 | "editorMode": "code", 324 | "exemplar": true, 325 | "expr": "max(rate(ETH2_totalDelayedAttestationsOverTolerance[10m]) / rate(ETH2_totalServedAttestations[10m])) by (service)", 326 | "interval": "", 327 | "legendFormat": "{{service}}", 328 | "range": true, 329 | "refId": "A" 330 | } 331 | ], 332 | "title": "Percentage Delayed Attestations Over Tolerance", 333 | "type": "timeseries" 334 | }, 335 | { 336 | "datasource": { 337 | "type": "prometheus", 338 | "uid": "PBFA97CFB590B2093" 339 | }, 340 | "fieldConfig": { 341 | "defaults": { 342 | "color": { 343 | "mode": "palette-classic" 344 | }, 345 | "custom": { 346 | "axisBorderShow": false, 347 | "axisCenteredZero": false, 348 | "axisColorMode": "text", 349 | "axisLabel": "", 350 | "axisPlacement": "auto", 351 | "barAlignment": 0, 352 | "barWidthFactor": 0.6, 353 | "drawStyle": "line", 354 | "fillOpacity": 0, 355 | "gradientMode": "none", 356 | "hideFrom": { 357 | "legend": false, 358 | "tooltip": false, 359 | "viz": false 360 | }, 361 | "insertNulls": false, 362 | "lineInterpolation": "linear", 363 | "lineWidth": 1, 364 | "pointSize": 5, 365 | "scaleDistribution": { 366 | "type": "linear" 367 | }, 368 | "showPoints": "auto", 369 | "showValues": false, 370 | "spanNulls": false, 371 | "stacking": { 372 | "group": "A", 373 | "mode": "none" 374 | }, 375 | "thresholdsStyle": { 376 | "mode": "off" 377 | } 378 | }, 379 | "mappings": [], 380 | "thresholds": { 381 | "mode": "absolute", 382 | "steps": [ 383 | { 384 | "color": "green", 385 | "value": 0 386 | }, 387 | { 388 | "color": "red", 389 | "value": 80 390 | } 391 | ] 392 | } 393 | }, 394 | "overrides": [] 395 | }, 396 | "gridPos": { 397 | "h": 8, 398 | "w": 6, 399 | "x": 12, 400 | "y": 8 401 | }, 402 | "id": 16, 403 | "options": { 404 | "legend": { 405 | "calcs": [], 406 | "displayMode": "list", 407 | "placement": "bottom", 408 | "showLegend": true 409 | }, 410 | "tooltip": { 411 | "hideZeros": false, 412 | "mode": "multi", 413 | "sort": "desc" 414 | } 415 | }, 416 | "pluginVersion": "12.2.1", 417 | "targets": [ 418 | { 419 | "datasource": { 420 | "type": "prometheus", 421 | "uid": "prometheus" 422 | }, 423 | "editorMode": "code", 424 | "exemplar": true, 425 | "expr": "max(ETH2_totalDelayedAttestationsOverTolerance) by (service)", 426 | "interval": "", 427 | "legendFormat": "{{service}}", 428 | "range": true, 429 | "refId": "A" 430 | } 431 | ], 432 | "title": "Total Delayed Attestations Over Tolerance", 433 | "type": "timeseries" 434 | }, 435 | { 436 | "datasource": { 437 | "type": "prometheus", 438 | "uid": "PBFA97CFB590B2093" 439 | }, 440 | "fieldConfig": { 441 | "defaults": { 442 | "color": { 443 | "mode": "palette-classic" 444 | }, 445 | "custom": { 446 | "axisBorderShow": false, 447 | "axisCenteredZero": false, 448 | "axisColorMode": "text", 449 | "axisLabel": "", 450 | "axisPlacement": "auto", 451 | "barAlignment": 0, 452 | "barWidthFactor": 0.6, 453 | "drawStyle": "line", 454 | "fillOpacity": 0, 455 | "gradientMode": "none", 456 | "hideFrom": { 457 | "legend": false, 458 | "tooltip": false, 459 | "viz": false 460 | }, 461 | "insertNulls": false, 462 | "lineInterpolation": "linear", 463 | "lineWidth": 1, 464 | "pointSize": 5, 465 | "scaleDistribution": { 466 | "type": "linear" 467 | }, 468 | "showPoints": "auto", 469 | "showValues": false, 470 | "spanNulls": false, 471 | "stacking": { 472 | "group": "A", 473 | "mode": "none" 474 | }, 475 | "thresholdsStyle": { 476 | "mode": "off" 477 | } 478 | }, 479 | "mappings": [], 480 | "thresholds": { 481 | "mode": "absolute", 482 | "steps": [ 483 | { 484 | "color": "green", 485 | "value": 0 486 | }, 487 | { 488 | "color": "red", 489 | "value": 80 490 | } 491 | ] 492 | } 493 | }, 494 | "overrides": [] 495 | }, 496 | "gridPos": { 497 | "h": 8, 498 | "w": 6, 499 | "x": 18, 500 | "y": 8 501 | }, 502 | "id": 17, 503 | "options": { 504 | "legend": { 505 | "calcs": [], 506 | "displayMode": "list", 507 | "placement": "bottom", 508 | "showLegend": true 509 | }, 510 | "tooltip": { 511 | "hideZeros": false, 512 | "mode": "multi", 513 | "sort": "desc" 514 | } 515 | }, 516 | "pluginVersion": "12.2.1", 517 | "targets": [ 518 | { 519 | "datasource": { 520 | "type": "prometheus", 521 | "uid": "prometheus" 522 | }, 523 | "editorMode": "code", 524 | "exemplar": true, 525 | "expr": "max(ETH2_totalServedAttestations) by (service)", 526 | "interval": "", 527 | "legendFormat": "{{service}}", 528 | "range": true, 529 | "refId": "A" 530 | } 531 | ], 532 | "title": "Total Served Attestations", 533 | "type": "timeseries" 534 | }, 535 | { 536 | "datasource": { 537 | "type": "prometheus", 538 | "uid": "PBFA97CFB590B2093" 539 | }, 540 | "fieldConfig": { 541 | "defaults": { 542 | "color": { 543 | "mode": "palette-classic" 544 | }, 545 | "custom": { 546 | "axisBorderShow": false, 547 | "axisCenteredZero": false, 548 | "axisColorMode": "text", 549 | "axisLabel": "", 550 | "axisPlacement": "auto", 551 | "barAlignment": 0, 552 | "barWidthFactor": 0.6, 553 | "drawStyle": "line", 554 | "fillOpacity": 0, 555 | "gradientMode": "none", 556 | "hideFrom": { 557 | "legend": false, 558 | "tooltip": false, 559 | "viz": false 560 | }, 561 | "insertNulls": false, 562 | "lineInterpolation": "linear", 563 | "lineWidth": 1, 564 | "pointSize": 5, 565 | "scaleDistribution": { 566 | "type": "linear" 567 | }, 568 | "showPoints": "auto", 569 | "showValues": false, 570 | "spanNulls": false, 571 | "stacking": { 572 | "group": "A", 573 | "mode": "none" 574 | }, 575 | "thresholdsStyle": { 576 | "mode": "off" 577 | } 578 | }, 579 | "mappings": [], 580 | "thresholds": { 581 | "mode": "absolute", 582 | "steps": [ 583 | { 584 | "color": "green", 585 | "value": 0 586 | }, 587 | { 588 | "color": "red", 589 | "value": 80 590 | } 591 | ] 592 | } 593 | }, 594 | "overrides": [] 595 | }, 596 | "gridPos": { 597 | "h": 8, 598 | "w": 6, 599 | "x": 0, 600 | "y": 16 601 | }, 602 | "id": 14, 603 | "options": { 604 | "legend": { 605 | "calcs": [], 606 | "displayMode": "list", 607 | "placement": "bottom", 608 | "showLegend": true 609 | }, 610 | "tooltip": { 611 | "hideZeros": false, 612 | "mode": "multi", 613 | "sort": "desc" 614 | } 615 | }, 616 | "pluginVersion": "12.2.1", 617 | "targets": [ 618 | { 619 | "datasource": { 620 | "type": "prometheus", 621 | "uid": "prometheus" 622 | }, 623 | "editorMode": "code", 624 | "exemplar": true, 625 | "expr": "max(ETH2_totalMissedProposals) by (service)", 626 | "interval": "", 627 | "legendFormat": "{{service}}", 628 | "range": true, 629 | "refId": "A" 630 | } 631 | ], 632 | "title": "Total Missed Proposals", 633 | "type": "timeseries" 634 | }, 635 | { 636 | "datasource": { 637 | "type": "prometheus", 638 | "uid": "PBFA97CFB590B2093" 639 | }, 640 | "fieldConfig": { 641 | "defaults": { 642 | "color": { 643 | "mode": "palette-classic" 644 | }, 645 | "custom": { 646 | "axisBorderShow": false, 647 | "axisCenteredZero": false, 648 | "axisColorMode": "text", 649 | "axisLabel": "", 650 | "axisPlacement": "auto", 651 | "barAlignment": 0, 652 | "barWidthFactor": 0.6, 653 | "drawStyle": "line", 654 | "fillOpacity": 0, 655 | "gradientMode": "none", 656 | "hideFrom": { 657 | "legend": false, 658 | "tooltip": false, 659 | "viz": false 660 | }, 661 | "insertNulls": false, 662 | "lineInterpolation": "linear", 663 | "lineWidth": 1, 664 | "pointSize": 5, 665 | "scaleDistribution": { 666 | "type": "linear" 667 | }, 668 | "showPoints": "auto", 669 | "showValues": false, 670 | "spanNulls": false, 671 | "stacking": { 672 | "group": "A", 673 | "mode": "none" 674 | }, 675 | "thresholdsStyle": { 676 | "mode": "off" 677 | } 678 | }, 679 | "mappings": [], 680 | "thresholds": { 681 | "mode": "absolute", 682 | "steps": [ 683 | { 684 | "color": "green", 685 | "value": 0 686 | }, 687 | { 688 | "color": "red", 689 | "value": 80 690 | } 691 | ] 692 | } 693 | }, 694 | "overrides": [] 695 | }, 696 | "gridPos": { 697 | "h": 8, 698 | "w": 6, 699 | "x": 6, 700 | "y": 16 701 | }, 702 | "id": 20, 703 | "options": { 704 | "legend": { 705 | "calcs": [], 706 | "displayMode": "list", 707 | "placement": "bottom", 708 | "showLegend": true 709 | }, 710 | "tooltip": { 711 | "hideZeros": false, 712 | "mode": "multi", 713 | "sort": "desc" 714 | } 715 | }, 716 | "pluginVersion": "12.2.1", 717 | "targets": [ 718 | { 719 | "datasource": { 720 | "type": "prometheus", 721 | "uid": "prometheus" 722 | }, 723 | "editorMode": "code", 724 | "exemplar": true, 725 | "expr": "max(ETH2_totalVanillaBlocks) by (service)", 726 | "interval": "", 727 | "legendFormat": "{{service}}", 728 | "range": true, 729 | "refId": "A" 730 | } 731 | ], 732 | "title": "Total Vanilla Blocks", 733 | "type": "timeseries" 734 | }, 735 | { 736 | "datasource": { 737 | "type": "prometheus", 738 | "uid": "PBFA97CFB590B2093" 739 | }, 740 | "fieldConfig": { 741 | "defaults": { 742 | "color": { 743 | "mode": "palette-classic" 744 | }, 745 | "custom": { 746 | "axisBorderShow": false, 747 | "axisCenteredZero": false, 748 | "axisColorMode": "text", 749 | "axisLabel": "", 750 | "axisPlacement": "auto", 751 | "barAlignment": 0, 752 | "barWidthFactor": 0.6, 753 | "drawStyle": "line", 754 | "fillOpacity": 0, 755 | "gradientMode": "none", 756 | "hideFrom": { 757 | "legend": false, 758 | "tooltip": false, 759 | "viz": false 760 | }, 761 | "insertNulls": false, 762 | "lineInterpolation": "linear", 763 | "lineWidth": 1, 764 | "pointSize": 5, 765 | "scaleDistribution": { 766 | "type": "linear" 767 | }, 768 | "showPoints": "auto", 769 | "showValues": false, 770 | "spanNulls": false, 771 | "stacking": { 772 | "group": "A", 773 | "mode": "none" 774 | }, 775 | "thresholdsStyle": { 776 | "mode": "off" 777 | } 778 | }, 779 | "mappings": [], 780 | "thresholds": { 781 | "mode": "absolute", 782 | "steps": [ 783 | { 784 | "color": "green", 785 | "value": 0 786 | }, 787 | { 788 | "color": "red", 789 | "value": 80 790 | } 791 | ] 792 | } 793 | }, 794 | "overrides": [] 795 | }, 796 | "gridPos": { 797 | "h": 8, 798 | "w": 6, 799 | "x": 12, 800 | "y": 16 801 | }, 802 | "id": 15, 803 | "options": { 804 | "legend": { 805 | "calcs": [], 806 | "displayMode": "list", 807 | "placement": "bottom", 808 | "showLegend": true 809 | }, 810 | "tooltip": { 811 | "hideZeros": false, 812 | "mode": "multi", 813 | "sort": "desc" 814 | } 815 | }, 816 | "pluginVersion": "12.2.1", 817 | "targets": [ 818 | { 819 | "datasource": { 820 | "type": "prometheus", 821 | "uid": "prometheus" 822 | }, 823 | "editorMode": "code", 824 | "exemplar": true, 825 | "expr": "max(ETH2_totalMissedAttestations) by (service)", 826 | "interval": "", 827 | "legendFormat": "{{service}}", 828 | "range": true, 829 | "refId": "A" 830 | } 831 | ], 832 | "title": "Total Missed Attestations", 833 | "type": "timeseries" 834 | } 835 | ], 836 | "preload": false, 837 | "refresh": "10s", 838 | "schemaVersion": 42, 839 | "tags": [], 840 | "templating": { 841 | "list": [] 842 | }, 843 | "time": { 844 | "from": "now-24h", 845 | "to": "now" 846 | }, 847 | "timepicker": {}, 848 | "timezone": "", 849 | "title": "ETH2 Monitor Exporter Dashboard", 850 | "uid": "hhTHois", 851 | "version": 1 852 | } 853 | --------------------------------------------------------------------------------