├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd └── relay-monitor │ └── main.go ├── config.example.yaml ├── go.mod ├── go.sum └── pkg ├── analysis ├── analysis.go ├── analyzer.go └── relay_faults.go ├── api └── server.go ├── builder ├── client.go └── client_test.go ├── consensus ├── client.go ├── client_test.go ├── clock.go ├── randao_support.go └── validator_status.go ├── crypto ├── signing.go └── signing_test.go ├── data ├── collector.go └── event.go ├── monitor ├── config.go └── monitor.go ├── store ├── store.go └── util.go └── types └── types.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: test 8 | runs-on: ubuntu-latest 9 | env: 10 | CGO_CFLAGS_ALLOW: "-O -D__BLST_PORTABLE__" 11 | CGO_CFLAGS: "-O -D__BLST_PORTABLE__" 12 | steps: 13 | - name: Set up Go 1.x 14 | uses: actions/setup-go@v3 15 | with: 16 | go-version: ^1.19 17 | id: go 18 | 19 | - name: Check out code into the Go module directory 20 | uses: actions/checkout@v3 21 | 22 | - name: Run unit tests 23 | run: make test 24 | 25 | lint: 26 | name: lint 27 | runs-on: ubuntu-latest 28 | env: 29 | CGO_CFLAGS_ALLOW: "-O -D__BLST_PORTABLE__" 30 | CGO_CFLAGS: "-O -D__BLST_PORTABLE__" 31 | steps: 32 | - name: Set up Go 1.x 33 | uses: actions/setup-go@v3 34 | with: 35 | go-version: ^1.19 36 | id: go 37 | 38 | - name: Check out code into the Go module directory 39 | uses: actions/checkout@v3 40 | 41 | - name: Install gofumpt 42 | run: go install mvdan.cc/gofumpt@latest 43 | 44 | - name: Install staticcheck 45 | run: go install honnef.co/go/tools/cmd/staticcheck@v0.3.3 46 | 47 | - name: Install golangci-lint 48 | run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin 49 | 50 | - name: Lint 51 | run: make lint 52 | 53 | - name: Ensure go mod tidy runs without changes 54 | run: | 55 | go mod tidy 56 | git diff-index HEAD 57 | git diff-index --quiet HEAD 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | relay-monitor 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alex Stokes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION ?= $(shell git describe --tags --always --dirty="-dev") 2 | 3 | all: clean build 4 | 5 | v: 6 | @echo "Version: ${VERSION}" 7 | 8 | clean: 9 | git clean -fdx 10 | 11 | build: 12 | go build -ldflags "-X cmd.Version=${VERSION} -X main.Version=${VERSION}" -v -o relay-monitor . 13 | 14 | test: 15 | go test ./... 16 | 17 | test-race: 18 | go test -race ./... 19 | 20 | lint: 21 | gofmt -d -s . 22 | gofumpt -d -extra . 23 | go vet ./... 24 | staticcheck ./... 25 | golangci-lint run 26 | 27 | gofumpt: 28 | gofumpt -l -w -extra . 29 | 30 | test-coverage: 31 | go test -race -v -covermode=atomic -coverprofile=coverage.out ./... 32 | go tool cover -func coverage.out 33 | 34 | cover-html: 35 | go test -coverprofile=/tmp/relay-monitor.cover.tmp ./... 36 | go tool cover -html=/tmp/relay-monitor.cover.tmp 37 | unlink /tmp/relay-monitor.cover.tmp 38 | 39 | docker-image: 40 | DOCKER_BUILDKIT=1 docker build --build-arg VERSION=${VERSION} . -t ralexstokes/relay-monitor 41 | 42 | docker-image-amd: 43 | DOCKER_BUILDKIT=1 docker build --platform linux/amd64 --build-arg VERSION=${VERSION} . -t ralexstokes/relay-monitor 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # relay-monitor 2 | 3 | A service to monitor a set of relayers in the external builder network on Ethereum. 4 | 5 | ## NOTE 6 | 7 | Work in progress. Not ready for use yet. 8 | 9 | ## Dependencies 10 | 11 | Go v1.19+ 12 | 13 | Requires a consensus client that implements the **dev** version of the standard [beacon node APIs](https://ethereum.github.io/beacon-APIs). This includes the standard set and [the RANDAO endpoint](https://ethereum.github.io/beacon-APIs/?urls.primaryName=dev#/Beacon/getStateRandao). 14 | 15 | Every other required information is given in the configuration, e.g. `config.example.yaml`. 16 | 17 | ## Operation 18 | 19 | `$ go run ./cmd/relay-monitor/main.go -config config.example.yaml` 20 | 21 | ## Implementation 22 | 23 | The monitor is structured as a series of components that ingest data and produce a live stream of fault data for each configured relay. 24 | 25 | ### `collector` 26 | 27 | The `collector` gathers data from either connected validators or relays it is configured to scan. 28 | 29 | - validator registrations 30 | - bids from relays 31 | - auction transcript (bid + signed blinded beacon block) from connected proposers 32 | - execution payload (conditional on auction transcript) 33 | - signed beacon block (collected from a consensus client) 34 | 35 | ### `analyzer` 36 | 37 | The `analyzer` component derives faults from the collected data. 38 | 39 | Each slot of the consensus protocol has a unique analysis which tries to derive any configured faults based on the observed data at a given time. 40 | 41 | It is defined as set of rules (defined in Go for now) that should be re-evaluated when any dependent piece of data is collected. 42 | 43 | The current fault count for each relay summarizing their behavior is maintained by the monitor and exposed via API. 44 | 45 | ## API docs 46 | 47 | ### POST `/eth/v1/builder/validators` 48 | 49 | Expose the `registerValidator` endpoint from the `builder-specs` APIs to accept `SignedValidatorRegistrationsV1` from connected proposers. 50 | 51 | See the [definition from the builder specs](https://ethereum.github.io/builder-specs/#/Builder/registerValidator) for more information. 52 | 53 | ### POST `/monitor/v1/transcript` 54 | 55 | Accept complete transcripts from proposers to verify the proposer's leg of the auction was performed correctly. 56 | 57 | This endpoint accepts the JSON encoding of the signed builder bid under a top-level key `"bid"` and the signed blinded beacon block under a top-level key `"acceptance"`. Encodings follow the JSON definition given in the [builder-specs](https://github.com/ethereum/builder-specs). 58 | 59 | This endpoint returns HTTP 200 OK upon success and HTTP 4XX otherwise. 60 | 61 | Example request: 62 | 63 | ```json 64 | { 65 | "bid": { 66 | "data": { 67 | "message": { 68 | "header": { 69 | "parent_hash": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 70 | "fee_recipient": "0xabcf8e0d4e9587369b2301d0790347320302cc09", 71 | "state_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 72 | "receipts_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 73 | "logs_bloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", 74 | "prev_randao": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 75 | "block_number": "1", 76 | "gas_limit": "1", 77 | "gas_used": "1", 78 | "timestamp": "1", 79 | "extra_data": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 80 | "base_fee_per_gas": "1", 81 | "block_hash": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 82 | "transactions_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" 83 | }, 84 | "value": "1", 85 | "pubkey": "0x93247f2209abcacf57b75a51dafae777f9dd38bc7053d1af526f220a7489a6d3a2753e5f3e8b1cfe39b56f43611df74a" 86 | }, 87 | "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" 88 | } 89 | }, 90 | "acceptance": { 91 | "message": { 92 | "slot": "1", 93 | "proposer_index": "1", 94 | "parent_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 95 | "state_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 96 | "body": { 97 | ... fields omitted ... 98 | "execution_payload_header": { 99 | "parent_hash": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 100 | "fee_recipient": "0xabcf8e0d4e9587369b2301d0790347320302cc09", 101 | "state_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 102 | "receipts_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 103 | "logs_bloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", 104 | "prev_randao": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 105 | "block_number": "1", 106 | "gas_limit": "1", 107 | "gas_used": "1", 108 | "timestamp": "1", 109 | "extra_data": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 110 | "base_fee_per_gas": "1", 111 | "block_hash": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", 112 | "transactions_root": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" 113 | } 114 | } 115 | }, 116 | "signature": "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505" 117 | } 118 | } 119 | ``` 120 | 121 | ### GET `/monitor/v1/faults` 122 | 123 | Exposes a summary of faults per relay. 124 | 125 | This response contains a map of relay public key to a mapping of observed fault counts. The response also contains the start and end epochs the observation window spans. 126 | 127 | The types of faults and their meaning can be found here: https://hackmd.io/A2uex3QFSfiaJJ9BKxw-XA?view#behavior-faults 128 | 129 | #### Optional query params: 130 | 131 | Query param: `start`, an unsigned 64-bit integer indicating the lower bound for an epoch to provide fault data for 132 | Query param: `end`, an unsigned 64-bit integer indicating the upper bound for an epoch to provide fault data for. 133 | Query param: `window`, an unsigned 64-bit integer indicating the size of the window to provide fault data for 134 | 135 | NOTE: if only `start` (or `end`) is provided then the response will only span the `window` size amount of epochs after (or before) the given parameter. the `window` parameter can optionally be specified as a query param or a default of `256` will be used if the query param is missing. 136 | NOTE: if neither parameter is provided, the response will be `256` epochs behind from the current epoch, inclusive. 137 | 138 | #### Example request: 139 | 140 | `GET /monitor/v1/faults?start=100` 141 | 142 | #### Example response: 143 | 144 | ```json 145 | { 146 | "span": { 147 | "start_epoch": "100", 148 | "end_epoch": "356", 149 | }, 150 | "data": { 151 | "0x845bd072b7cd566f02faeb0a4033ce9399e42839ced64e8b2adcfc859ed1e8e1a5a293336a49feac6d9a5edb779be53a": { 152 | "stats": { 153 | "total_bids": 1153, 154 | "malformed_bids": 0, 155 | "consensus_invalid_bids": 1, 156 | "payment_invalid_bids": 12, 157 | "ignored_preferences_bids": 5, 158 | "malformed_payloads": 0, 159 | "consensus_invalid_payloads": 1, 160 | "unavailable_payloads": 10 161 | }, 162 | "meta": { 163 | "endpoint": "builder-relay-sepolia.flashbots.net" 164 | } 165 | } 166 | } 167 | } 168 | ``` 169 | -------------------------------------------------------------------------------- /cmd/relay-monitor/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "os" 8 | 9 | "github.com/ralexstokes/relay-monitor/pkg/monitor" 10 | "go.uber.org/zap" 11 | "go.uber.org/zap/zapcore" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | var configFile = flag.String("config", "config.example.yaml", "path to config file") 16 | 17 | func main() { 18 | flag.Parse() 19 | 20 | loggingConfig := zap.NewDevelopmentConfig() 21 | loggingConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder 22 | 23 | zapLogger, err := loggingConfig.Build() 24 | if err != nil { 25 | log.Fatalf("could not open log file: %v", err) 26 | } 27 | defer func() { 28 | err := zapLogger.Sync() 29 | if err != nil { 30 | log.Fatalf("could not flush log: %v", err) 31 | } 32 | }() 33 | 34 | logger := zapLogger.Sugar() 35 | 36 | data, err := os.ReadFile(*configFile) 37 | if err != nil { 38 | logger.Fatalf("could not read config file: %v", err) 39 | } 40 | 41 | config := &monitor.Config{} 42 | err = yaml.Unmarshal(data, config) 43 | if err != nil { 44 | logger.Fatalf("could not load config: %v", err) 45 | } 46 | 47 | ctx := context.Background() 48 | logger.Infof("starting relay monitor for %s network", config.Network.Name) 49 | m, err := monitor.New(ctx, config, zapLogger) 50 | if err != nil { 51 | logger.Fatalf("could not start relay monitor: %v", err) 52 | } 53 | 54 | m.Run(ctx) 55 | } 56 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | network: 3 | name: "sepolia" 4 | consensus: 5 | endpoint: "http://127.0.0.1:5052" 6 | relays: 7 | - "https://0x845bd072b7cd566f02faeb0a4033ce9399e42839ced64e8b2adcfc859ed1e8e1a5a293336a49feac6d9a5edb779be53a@builder-relay-sepolia.flashbots.net" 8 | api: 9 | host: "localhost" 10 | port: 8080 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ralexstokes/relay-monitor 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/ethereum/go-ethereum v1.10.25 7 | github.com/flashbots/go-boost-utils v1.2.2 8 | github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d 9 | github.com/holiman/uint256 v1.2.1 10 | github.com/protolambda/eth2api v0.0.0-20220822011642-f7735dd471e0 11 | github.com/protolambda/zrnt v0.28.0 12 | github.com/r3labs/sse/v2 v2.8.1 13 | go.uber.org/zap v1.22.0 14 | gopkg.in/yaml.v3 v3.0.1 15 | ) 16 | 17 | require ( 18 | github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6 // indirect 19 | github.com/VictoriaMetrics/fastcache v1.6.0 // indirect 20 | github.com/btcsuite/btcd/btcec/v2 v2.2.1 // indirect 21 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 22 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect 23 | github.com/ferranbt/fastssz v0.1.2-0.20220723134332-b3d3034a4575 // indirect 24 | github.com/go-ole/go-ole v1.2.1 // indirect 25 | github.com/go-stack/stack v1.8.0 // indirect 26 | github.com/golang/snappy v0.0.4 // indirect 27 | github.com/julienschmidt/httprouter v1.3.0 // indirect 28 | github.com/kilic/bls12-381 v0.1.0 // indirect 29 | github.com/klauspost/cpuid/v2 v2.1.0 // indirect 30 | github.com/mattn/go-runewidth v0.0.9 // indirect 31 | github.com/minio/sha256-simd v1.0.0 // indirect 32 | github.com/mitchellh/mapstructure v1.5.0 // indirect 33 | github.com/olekukonko/tablewriter v0.0.5 // indirect 34 | github.com/pkg/errors v0.9.1 // indirect 35 | github.com/prometheus/tsdb v0.7.1 // indirect 36 | github.com/protolambda/bls12-381-util v0.0.0-20210720105258-a772f2aac13e // indirect 37 | github.com/protolambda/ztyp v0.2.2 // indirect 38 | github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect 39 | github.com/supranational/blst v0.3.8-0.20220526154634-513d2456b344 // indirect 40 | github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect 41 | github.com/tklauser/go-sysconf v0.3.5 // indirect 42 | github.com/tklauser/numcpus v0.2.2 // indirect 43 | go.uber.org/atomic v1.10.0 // indirect 44 | go.uber.org/multierr v1.8.0 // indirect 45 | golang.org/x/crypto v0.1.0 // indirect 46 | golang.org/x/net v0.1.0 // indirect 47 | golang.org/x/sys v0.1.0 // indirect 48 | gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect 49 | gopkg.in/yaml.v2 v2.4.0 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 2 | github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6 h1:fLjPD/aNc3UIOA6tDi6QXUemppXK3P9BI7mr2hd6gx8= 3 | github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= 4 | github.com/VictoriaMetrics/fastcache v1.6.0 h1:C/3Oi3EiBCqufydp1neRZkqcwmEiuRT9c3fqvvgKm5o= 5 | github.com/VictoriaMetrics/fastcache v1.6.0/go.mod h1:0qHz5QP0GMX4pfmMA/zt5RgfNuXJrTP0zS7DqpHGGTw= 6 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 7 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 8 | github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8= 9 | github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= 10 | github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= 11 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 12 | github.com/btcsuite/btcd/btcec/v2 v2.2.1 h1:xP60mv8fvp+0khmrN0zTdPC3cNm24rfeE6lh2R/Yv3E= 13 | github.com/btcsuite/btcd/btcec/v2 v2.2.1/go.mod h1:9/CSmJxmuvqzX9Wh2fXMWToLOHhPd11lSPuIupwTkI8= 14 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= 15 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 16 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 17 | github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= 18 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 19 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 21 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= 23 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4= 24 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= 25 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 26 | github.com/ethereum/go-ethereum v1.10.25 h1:5dFrKJDnYf8L6/5o42abCE6a9yJm9cs4EJVRyYMr55s= 27 | github.com/ethereum/go-ethereum v1.10.25/go.mod h1:EYFyF19u3ezGLD4RqOkLq+ZCXzYbLoNDdZlMt7kyKFg= 28 | github.com/ferranbt/fastssz v0.1.2-0.20220723134332-b3d3034a4575 h1:56lKKtcqQZ5sGjeuyBAeFwzcYuk32d8oqDvxQ9FUERA= 29 | github.com/ferranbt/fastssz v0.1.2-0.20220723134332-b3d3034a4575/go.mod h1:U2ZsxlYyvGeQGmadhz8PlEqwkBzDIhHwd3xuKrg2JIs= 30 | github.com/flashbots/go-boost-utils v1.2.2 h1:KoIQHAveSwzJQceZLMBdxPwM/IAOmDZ30E6xQz37MEA= 31 | github.com/flashbots/go-boost-utils v1.2.2/go.mod h1:XxZ1vM0bwnHTGyqmzjrXcBbNbGXBxmVdeyglOCcC+/E= 32 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 33 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 34 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 35 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 36 | github.com/go-kit/kit v0.9.0 h1:wDJmvq38kDhkVxi50ni9ykkdUr1PKgqKOoi01fa0Mdk= 37 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 38 | github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4= 39 | github.com/go-ole/go-ole v1.2.1 h1:2lOsA72HgjxAuMlKpFiCbHTvu44PIVkZ5hqm3RSdI/E= 40 | github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= 41 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= 42 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 43 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 44 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 45 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 46 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 47 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 48 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 49 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 50 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 51 | github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 52 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 53 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 54 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 55 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 56 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 57 | github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d h1:dg1dEPuWpEqDnvIw251EVy4zlP8gWbsGj4BsUKCRpYs= 58 | github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 59 | github.com/holiman/uint256 v1.2.0/go.mod h1:y4ga/t+u+Xwd7CpDgZESaRcWy0I7XMlTMA25ApIH5Jw= 60 | github.com/holiman/uint256 v1.2.1 h1:XRtyuda/zw2l+Bq/38n5XUoEF72aSOu/77Thd9pPp2o= 61 | github.com/holiman/uint256 v1.2.1/go.mod h1:y4ga/t+u+Xwd7CpDgZESaRcWy0I7XMlTMA25ApIH5Jw= 62 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 63 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 64 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 65 | github.com/kilic/bls12-381 v0.1.0 h1:encrdjqKMEvabVQ7qYOKu1OvhqpK4s47wDYtNiPtlp4= 66 | github.com/kilic/bls12-381 v0.1.0/go.mod h1:vDTTHJONJ6G+P2R74EhnyotQDTliQDnFEwhdmfzw1ig= 67 | github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 68 | github.com/klauspost/cpuid/v2 v2.1.0 h1:eyi1Ad2aNJMW95zcSbmGg7Cg6cq3ADwLpMAP96d8rF0= 69 | github.com/klauspost/cpuid/v2 v2.1.0/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 70 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 71 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 72 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 73 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 74 | github.com/minio/sha256-simd v0.1.0/go.mod h1:2FMWW+8GMoPweT6+pI63m9YE3Lmw4J71hV56Chs1E/U= 75 | github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= 76 | github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= 77 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 78 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 79 | github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= 80 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 81 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 82 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 83 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 84 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 85 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 86 | github.com/onsi/ginkgo v1.14.0 h1:2mOpI4JVVPBN+WQRa0WKH2eXR+Ey+uK4n7Zj0aYpIQA= 87 | github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= 88 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 89 | github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= 90 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 91 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 92 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 93 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 94 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 95 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 96 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 97 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 98 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 99 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 100 | github.com/prometheus/tsdb v0.7.1 h1:YZcsG11NqnK4czYLrWd9mpEuAJIHVQLwdrleYfszMAA= 101 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 102 | github.com/protolambda/bls12-381-util v0.0.0-20210720105258-a772f2aac13e h1:ugvwIKDzqL6ODJciRPMm+9xFQ5AlOYHeMpCOeEuP7LA= 103 | github.com/protolambda/bls12-381-util v0.0.0-20210720105258-a772f2aac13e/go.mod h1:MPZvj2Pr0N8/dXyTPS5REeg2sdLG7t8DRzC1rLv925w= 104 | github.com/protolambda/eth2api v0.0.0-20220822011642-f7735dd471e0 h1:/0Dm3GrX4sk0aAYdrjtFvJigvPZ8HcAQ3ZT7kW+da7M= 105 | github.com/protolambda/eth2api v0.0.0-20220822011642-f7735dd471e0/go.mod h1:dzegbgrVD+2MQZ86RHWQvgWfnFxEjlDNkzf69afaSnA= 106 | github.com/protolambda/messagediff v1.4.0/go.mod h1:LboJp0EwIbJsePYpzh5Op/9G1/4mIztMRYzzwR0dR2M= 107 | github.com/protolambda/zrnt v0.28.0 h1:vdEL8JDqJ3wdzgqgh6Fhz1Wr3+AMGbUZ2nqoNt6QVX0= 108 | github.com/protolambda/zrnt v0.28.0/go.mod h1:qcdX9CXFeVNCQK/q0nswpzhd+31RHMk2Ax/2lMsJ4Jw= 109 | github.com/protolambda/ztyp v0.2.2 h1:rVcL3vBu9W/aV646zF6caLS/dyn9BN8NYiuJzicLNyY= 110 | github.com/protolambda/ztyp v0.2.2/go.mod h1:9bYgKGqg3wJqT9ac1gI2hnVb0STQq7p/1lapqrqY1dU= 111 | github.com/r3labs/sse/v2 v2.8.1 h1:lZH+W4XOLIq88U5MIHOsLec7+R62uhz3bIi2yn0Sg8o= 112 | github.com/r3labs/sse/v2 v2.8.1/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= 113 | github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= 114 | github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= 115 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 116 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 117 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 118 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 119 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 120 | github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= 121 | github.com/supranational/blst v0.3.8-0.20220526154634-513d2456b344 h1:m+8fKfQwCAy1QjzINvKe/pYtLjo2dl59x2w9YSEJxuY= 122 | github.com/supranational/blst v0.3.8-0.20220526154634-513d2456b344/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= 123 | github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= 124 | github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= 125 | github.com/tklauser/go-sysconf v0.3.5 h1:uu3Xl4nkLzQfXNsWn15rPc/HQCJKObbt1dKJeWp3vU4= 126 | github.com/tklauser/go-sysconf v0.3.5/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= 127 | github.com/tklauser/numcpus v0.2.2 h1:oyhllyrScuYI6g+h/zUvNXNp1wy7x8qQy3t/piefldA= 128 | github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= 129 | github.com/trailofbits/go-fuzz-utils v0.0.0-20210901195358-9657fcfd256c h1:4WU+p200eLYtBsx3M5CKXvkjVdf5SC3W9nMg37y0TFI= 130 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 131 | go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= 132 | go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 133 | go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= 134 | go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= 135 | go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= 136 | go.uber.org/zap v1.22.0 h1:Zcye5DUgBloQ9BaT4qc9BnjOFog5TvBSAGkJ3Nf70c0= 137 | go.uber.org/zap v1.22.0/go.mod h1:H4siCOZOrAolnUPJEkfaSjDqyP+BDS0DdDWzwcgt3+U= 138 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 139 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 140 | golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= 141 | golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 142 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 143 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 144 | golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 145 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 146 | golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 147 | golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= 148 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 149 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 150 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 151 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 152 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 153 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 154 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 155 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 156 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 157 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 158 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 159 | golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 160 | golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 161 | golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 162 | golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 163 | golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 164 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 165 | golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= 166 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 167 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 168 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 169 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 170 | golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= 171 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 172 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 173 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 174 | golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618= 175 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 176 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 177 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 178 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 179 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 180 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 181 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 182 | gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= 183 | gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= 184 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 185 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 186 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 187 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 188 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 189 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 190 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 191 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 192 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 193 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 194 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 195 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 196 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 197 | -------------------------------------------------------------------------------- /pkg/analysis/analysis.go: -------------------------------------------------------------------------------- 1 | package analysis 2 | 3 | type InvalidBid struct { 4 | Reason string 5 | Type uint 6 | Context map[string]interface{} 7 | } 8 | 9 | const ( 10 | InvalidBidConsensusType uint = iota 11 | InvalidBidIgnoredPreferencesType 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/analysis/analyzer.go: -------------------------------------------------------------------------------- 1 | package analysis 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/holiman/uint256" 8 | "github.com/ralexstokes/relay-monitor/pkg/builder" 9 | "github.com/ralexstokes/relay-monitor/pkg/consensus" 10 | "github.com/ralexstokes/relay-monitor/pkg/crypto" 11 | "github.com/ralexstokes/relay-monitor/pkg/data" 12 | "github.com/ralexstokes/relay-monitor/pkg/store" 13 | "github.com/ralexstokes/relay-monitor/pkg/types" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | const ( 18 | GasLimitBoundDivisor = 1024 19 | ) 20 | 21 | type Analyzer struct { 22 | logger *zap.Logger 23 | 24 | events <-chan data.Event 25 | 26 | store store.Storer 27 | consensusClient *consensus.Client 28 | clock *consensus.Clock 29 | 30 | faults FaultRecord 31 | faultsLock sync.Mutex 32 | } 33 | 34 | func NewAnalyzer(logger *zap.Logger, relays []*builder.Client, events <-chan data.Event, store store.Storer, consensusClient *consensus.Client, clock *consensus.Clock) *Analyzer { 35 | faults := make(FaultRecord) 36 | for _, relay := range relays { 37 | faults[relay.PublicKey] = &Faults{ 38 | Stats: &FaultStats{}, 39 | Meta: &Meta{ 40 | Endpoint: relay.Hostname(), 41 | }, 42 | } 43 | } 44 | return &Analyzer{ 45 | logger: logger, 46 | events: events, 47 | store: store, 48 | consensusClient: consensusClient, 49 | clock: clock, 50 | faults: faults, 51 | } 52 | } 53 | 54 | func (a *Analyzer) GetFaults(start, end types.Epoch) FaultRecord { 55 | a.faultsLock.Lock() 56 | defer a.faultsLock.Unlock() 57 | 58 | faults := make(FaultRecord) 59 | for relay, summary := range a.faults { 60 | summary := *summary 61 | faults[relay] = &summary 62 | } 63 | 64 | return faults 65 | } 66 | 67 | func (a *Analyzer) validateGasLimit(ctx context.Context, gasLimit uint64, gasLimitPreference uint64, blockNumber uint64) (bool, error) { 68 | if gasLimit == gasLimitPreference { 69 | return true, nil 70 | } 71 | 72 | parentGasLimit, err := a.consensusClient.GetParentGasLimit(ctx, blockNumber) 73 | if err != nil { 74 | return false, err 75 | } 76 | 77 | var expectedBound uint64 78 | if gasLimitPreference > gasLimit { 79 | expectedBound = parentGasLimit + (parentGasLimit / GasLimitBoundDivisor) 80 | } else { 81 | expectedBound = parentGasLimit - (parentGasLimit / GasLimitBoundDivisor) 82 | } 83 | 84 | return gasLimit == expectedBound, nil 85 | } 86 | 87 | // borrowed from `flashbots/go-boost-utils` 88 | func reverse(src []byte) []byte { 89 | dst := make([]byte, len(src)) 90 | copy(dst, src) 91 | for i := len(dst)/2 - 1; i >= 0; i-- { 92 | opp := len(dst) - 1 - i 93 | dst[i], dst[opp] = dst[opp], dst[i] 94 | } 95 | return dst 96 | } 97 | 98 | func (a *Analyzer) validateBid(ctx context.Context, bidCtx *types.BidContext, bid *types.Bid) (*InvalidBid, error) { 99 | if bid == nil { 100 | return nil, nil 101 | } 102 | 103 | if bidCtx.RelayPublicKey != bid.Message.Pubkey { 104 | return &InvalidBid{ 105 | Reason: "incorrect public key from relay", 106 | }, nil 107 | } 108 | 109 | validSignature, err := crypto.VerifySignature(bid.Message, a.consensusClient.SignatureDomainForBuilder(), bid.Message.Pubkey[:], bid.Signature[:]) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | if !validSignature { 115 | return &InvalidBid{ 116 | Reason: "invalid signature", 117 | }, nil 118 | } 119 | 120 | header := bid.Message.Header 121 | 122 | if bidCtx.ParentHash != header.ParentHash { 123 | return &InvalidBid{ 124 | Reason: "invalid parent hash", 125 | }, nil 126 | } 127 | 128 | registration, err := store.GetLatestValidatorRegistration(ctx, a.store, &bidCtx.ProposerPublicKey) 129 | if err != nil { 130 | return nil, err 131 | } 132 | if registration != nil { 133 | gasLimitPreference := registration.Message.GasLimit 134 | 135 | // NOTE: need transaction set for possibility of payment transaction 136 | // so we defer analysis of fee recipient until we have the full payload 137 | 138 | valid, err := a.validateGasLimit(ctx, header.GasLimit, gasLimitPreference, header.BlockNumber) 139 | if err != nil { 140 | return nil, err 141 | } 142 | if !valid { 143 | return &InvalidBid{ 144 | Reason: "invalid gas limit", 145 | Type: InvalidBidIgnoredPreferencesType, 146 | }, nil 147 | } 148 | } 149 | 150 | expectedRandomness, err := a.consensusClient.GetRandomnessForProposal(bidCtx.Slot) 151 | if err != nil { 152 | return nil, err 153 | } 154 | if expectedRandomness != header.Random { 155 | return &InvalidBid{ 156 | Reason: "invalid random value", 157 | }, nil 158 | } 159 | 160 | expectedBlockNumber, err := a.consensusClient.GetBlockNumberForProposal(bidCtx.Slot) 161 | if err != nil { 162 | return nil, err 163 | } 164 | if expectedBlockNumber != header.BlockNumber { 165 | return &InvalidBid{ 166 | Reason: "invalid block number", 167 | }, nil 168 | } 169 | 170 | if header.GasUsed > header.GasLimit { 171 | return &InvalidBid{ 172 | Reason: "gas used is higher than gas limit", 173 | }, nil 174 | } 175 | 176 | expectedTimestamp := a.clock.SlotInSeconds(bidCtx.Slot) 177 | if expectedTimestamp != int64(header.Timestamp) { 178 | return &InvalidBid{ 179 | Reason: "invalid timestamp", 180 | }, nil 181 | } 182 | 183 | expectedBaseFee, err := a.consensusClient.GetBaseFeeForProposal(bidCtx.Slot) 184 | if err != nil { 185 | return nil, err 186 | } 187 | baseFee := uint256.NewInt(0) 188 | baseFee.SetBytes(reverse(header.BaseFeePerGas[:])) 189 | if !expectedBaseFee.Eq(baseFee) { 190 | return &InvalidBid{ 191 | Reason: "invalid base fee", 192 | }, nil 193 | } 194 | 195 | return nil, nil 196 | } 197 | 198 | func (a *Analyzer) processBid(ctx context.Context, event *data.BidEvent) { 199 | logger := a.logger.Sugar() 200 | 201 | bidCtx := event.Context 202 | bid := event.Bid 203 | 204 | err := a.store.PutBid(ctx, bidCtx, bid) 205 | if err != nil { 206 | logger.Warnf("could not store bid: %+v", event) 207 | return 208 | } 209 | 210 | result, err := a.validateBid(ctx, bidCtx, bid) 211 | if err != nil { 212 | logger.Warnf("could not validate bid with error %+v: %+v, %+v", err, bidCtx, bid) 213 | return 214 | } 215 | 216 | // TODO scope faults by coordinate 217 | // TODO persist analysis results 218 | relayID := bidCtx.RelayPublicKey 219 | a.faultsLock.Lock() 220 | faults := a.faults[relayID] 221 | if bid != nil { 222 | faults.Stats.TotalBids += 1 223 | } 224 | if result != nil { 225 | switch result.Type { 226 | case InvalidBidConsensusType: 227 | faults.Stats.ConsensusInvalidBids += 1 228 | case InvalidBidIgnoredPreferencesType: 229 | faults.Stats.IgnoredPreferencesBids += 1 230 | default: 231 | logger.Warnf("could not interpret bid analysis result: %+v, %+v", event, result) 232 | return 233 | } 234 | } 235 | a.faultsLock.Unlock() 236 | if result != nil { 237 | logger.Debugf("invalid bid: %+v, %+v", result, event) 238 | } else { 239 | logger.Debugf("found valid bid: %+v, %+v", bidCtx, bid) 240 | } 241 | } 242 | 243 | // Process incoming validator registrations 244 | // This data has already been validated by the sender of the event 245 | func (a *Analyzer) processValidatorRegistration(ctx context.Context, event data.ValidatorRegistrationEvent) { 246 | logger := a.logger.Sugar() 247 | 248 | registrations := event.Registrations 249 | logger.Debugf("received %d validator registrations", len(registrations)) 250 | for _, registration := range registrations { 251 | err := a.store.PutValidatorRegistration(ctx, ®istration) 252 | if err != nil { 253 | logger.Warnf("could not store validator registration: %+v", registration) 254 | return 255 | } 256 | } 257 | } 258 | 259 | func (a *Analyzer) processAuctionTranscript(ctx context.Context, event data.AuctionTranscriptEvent) { 260 | logger := a.logger.Sugar() 261 | 262 | logger.Debugf("received transcript: %+v", event.Transcript) 263 | 264 | transcript := event.Transcript 265 | 266 | bid := transcript.Bid.Message 267 | signedBlindedBeaconBlock := &transcript.Acceptance 268 | blindedBeaconBlock := signedBlindedBeaconBlock.Message 269 | 270 | // Verify signature first, to avoid doing unnecessary work in the event this is a "bad" transcript 271 | proposerPublicKey, err := a.consensusClient.GetPublicKeyForIndex(ctx, blindedBeaconBlock.ProposerIndex) 272 | if err != nil { 273 | logger.Warnw("could not find public key for validator index", "error", err) 274 | return 275 | } 276 | 277 | domain := a.consensusClient.SignatureDomain(blindedBeaconBlock.Slot) 278 | valid, err := crypto.VerifySignature(signedBlindedBeaconBlock.Message, domain, proposerPublicKey[:], signedBlindedBeaconBlock.Signature[:]) 279 | if err != nil { 280 | logger.Warnw("error verifying signature from proposer; could not determine authenticity of transcript", "error", err, "bid", bid, "acceptance", signedBlindedBeaconBlock) 281 | return 282 | } 283 | if !valid { 284 | logger.Warnw("signature from proposer was invalid; could not determine authenticity of transcript", "error", err, "bid", bid, "acceptance", signedBlindedBeaconBlock) 285 | return 286 | } 287 | 288 | bidCtx := &types.BidContext{ 289 | Slot: blindedBeaconBlock.Slot, 290 | ParentHash: bid.Header.ParentHash, 291 | ProposerPublicKey: *proposerPublicKey, 292 | RelayPublicKey: bid.Pubkey, 293 | } 294 | existingBid, err := a.store.GetBid(ctx, bidCtx) 295 | if err != nil { 296 | logger.Warnw("could not find existing bid, will continue full analysis", "context", bidCtx) 297 | 298 | // TODO: process bid as well as rest of transcript 299 | } 300 | 301 | if existingBid != nil && *existingBid != transcript.Bid { 302 | logger.Warnw("provided bid from transcript did not match existing bid, will continue full analysis", "context", bidCtx) 303 | 304 | // TODO: process bid as well as rest of transcript 305 | } 306 | 307 | // TODO also store bid if missing? 308 | err = a.store.PutAcceptance(ctx, bidCtx, signedBlindedBeaconBlock) 309 | if err != nil { 310 | logger.Warnf("could not store bid acceptance data: %+v", event) 311 | return 312 | } 313 | 314 | // verify later w/ full payload: 315 | // (claimed) Value, including fee recipient 316 | // expectedFeeRecipient := registration.Message.FeeRecipient 317 | // if expectedFeeRecipient != header.FeeRecipient { 318 | // return &InvalidBid{ 319 | // Reason: "invalid fee recipient", 320 | // Type: InvalidBidIgnoredPreferencesType, 321 | // Context: map[string]interface{}{ 322 | // "expected fee recipient": expectedFeeRecipient, 323 | // "fee recipient in header": header.FeeRecipient, 324 | // }, 325 | // }, nil 326 | // } 327 | 328 | // BlockHash 329 | // StateRoot 330 | // ReceiptsRoot 331 | // LogsBloom 332 | // TransactionsRoot 333 | 334 | // TODO save analysis results 335 | 336 | } 337 | 338 | func (a *Analyzer) Run(ctx context.Context) error { 339 | logger := a.logger.Sugar() 340 | 341 | for { 342 | select { 343 | case event := <-a.events: 344 | switch event := event.Payload.(type) { 345 | case *data.BidEvent: 346 | a.processBid(ctx, event) 347 | case data.ValidatorRegistrationEvent: 348 | a.processValidatorRegistration(ctx, event) 349 | case data.AuctionTranscriptEvent: 350 | a.processAuctionTranscript(ctx, event) 351 | default: 352 | logger.Warnf("unknown event type %T for event %+v!", event, event) 353 | } 354 | case <-ctx.Done(): 355 | return nil 356 | } 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /pkg/analysis/relay_faults.go: -------------------------------------------------------------------------------- 1 | package analysis 2 | 3 | import "github.com/ralexstokes/relay-monitor/pkg/types" 4 | 5 | type FaultRecord = map[types.PublicKey]*Faults 6 | 7 | type Faults struct { 8 | Stats *FaultStats `json:"stats"` 9 | Meta *Meta `json:"meta"` 10 | } 11 | 12 | type FaultStats struct { 13 | TotalBids uint `json:"total_bids"` 14 | 15 | ConsensusInvalidBids uint `json:"consensus_invalid_bids"` 16 | IgnoredPreferencesBids uint `json:"ignored_preferences_bids"` 17 | 18 | PaymentInvalidBids uint `json:"payment_invalid_bids"` 19 | MalformedPayloads uint `json:"malformed_payloads"` 20 | ConsensusInvalidPayloads uint `json:"consensus_invalid_payloads"` 21 | UnavailablePayloads uint `json:"unavailable_payloads"` 22 | } 23 | 24 | type Meta struct { 25 | Endpoint string `json:"endpoint"` 26 | } 27 | -------------------------------------------------------------------------------- /pkg/api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "math" 8 | "net/http" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/ralexstokes/relay-monitor/pkg/analysis" 13 | "github.com/ralexstokes/relay-monitor/pkg/consensus" 14 | "github.com/ralexstokes/relay-monitor/pkg/crypto" 15 | "github.com/ralexstokes/relay-monitor/pkg/data" 16 | "github.com/ralexstokes/relay-monitor/pkg/store" 17 | "github.com/ralexstokes/relay-monitor/pkg/types" 18 | "go.uber.org/zap" 19 | ) 20 | 21 | const ( 22 | methodNotSupported = "method not supported" 23 | GetFaultEndpoint = "/monitor/v1/faults" 24 | RegisterValidatorEndpoint = "/eth/v1/builder/validators" 25 | PostAuctionTranscriptEndpoint = "/monitor/v1/transcript" 26 | DefaultEpochSpanForFaultsWindow = 256 27 | ) 28 | 29 | type Config struct { 30 | Host string `yaml:"host"` 31 | Port uint16 `yaml:"port"` 32 | } 33 | 34 | type Span struct { 35 | Start types.Epoch `json:"start_epoch,string"` 36 | End types.Epoch `json:"end_epoch,string"` 37 | } 38 | 39 | type FaultsResponse struct { 40 | Span Span `json:"span"` 41 | analysis.FaultRecord `json:"data"` 42 | } 43 | 44 | type Server struct { 45 | config *Config 46 | logger *zap.Logger 47 | 48 | analyzer *analysis.Analyzer 49 | events chan<- data.Event 50 | clock *consensus.Clock 51 | store store.Storer 52 | consensusClient *consensus.Client 53 | } 54 | 55 | func New(config *Config, logger *zap.Logger, analyzer *analysis.Analyzer, events chan<- data.Event, clock *consensus.Clock, store store.Storer, consensusClient *consensus.Client) *Server { 56 | return &Server{ 57 | config: config, 58 | logger: logger, 59 | analyzer: analyzer, 60 | events: events, 61 | clock: clock, 62 | store: store, 63 | consensusClient: consensusClient, 64 | } 65 | } 66 | 67 | // `computeSpan` ensures that `startEpoch` and `endEpoch` cover a "sensible" span where: 68 | // - `endEpoch` - `startEpoch` == `span` such that `startEpoch` >= 0 and `endEpoch` <= `math.MaxUint64` 69 | // (so that the span is smaller than requested against the boundaries) 70 | func computeSpanFromRequest(startEpochRequest, endEpochRequest *types.Epoch, targetSpan uint64, currentEpoch types.Epoch) (types.Epoch, types.Epoch) { 71 | var startEpoch types.Epoch 72 | endEpoch := currentEpoch 73 | 74 | if startEpochRequest == nil && endEpochRequest == nil { 75 | diff := int(endEpoch) - int(targetSpan) 76 | if diff < 0 { 77 | startEpoch = 0 78 | } else { 79 | startEpoch = types.Epoch(diff) 80 | } 81 | } else if startEpochRequest != nil && endEpochRequest == nil { 82 | startEpoch = *startEpochRequest 83 | boundary := math.MaxUint64 - targetSpan 84 | if startEpoch > boundary { 85 | diff := startEpoch - boundary 86 | endEpoch = startEpoch + diff 87 | } else { 88 | endEpoch = startEpoch + targetSpan 89 | } 90 | } else if startEpochRequest == nil && endEpochRequest != nil { 91 | endEpoch = *endEpochRequest 92 | if endEpoch > targetSpan { 93 | startEpoch = endEpoch - targetSpan 94 | } else { 95 | startEpoch = 0 96 | } 97 | } else { 98 | startEpoch = *startEpochRequest 99 | endEpoch = *endEpochRequest 100 | } 101 | // TODO these can be quite far apart... scope so a caller can't cause a large amount of work 102 | return startEpoch, endEpoch 103 | } 104 | 105 | func (s *Server) currentEpoch() types.Epoch { 106 | now := time.Now().Unix() 107 | slot := s.clock.CurrentSlot(now) 108 | return s.clock.EpochForSlot(slot) 109 | } 110 | 111 | func (s *Server) handleFaultsRequest(w http.ResponseWriter, r *http.Request) { 112 | logger := s.logger.Sugar() 113 | 114 | q := r.URL.Query() 115 | 116 | startEpochStr := q.Get("start") 117 | var startEpochRequest *types.Epoch 118 | if startEpochStr != "" { 119 | startEpochValue, err := strconv.ParseUint(startEpochStr, 10, 64) 120 | if err != nil { 121 | logger.Errorw("error parsing query param for faults request", "err", err, "startEpoch", startEpochStr) 122 | http.Error(w, err.Error(), http.StatusBadRequest) 123 | return 124 | } 125 | epoch := types.Epoch(startEpochValue) 126 | startEpochRequest = &epoch 127 | } 128 | 129 | endEpochStr := q.Get("end") 130 | var endEpochRequest *types.Epoch 131 | if endEpochStr != "" { 132 | endEpochValue, err := strconv.ParseUint(endEpochStr, 10, 64) 133 | if err != nil { 134 | logger.Errorw("error parsing query param for faults request", "err", err, "endEpoch", endEpochStr) 135 | http.Error(w, err.Error(), http.StatusBadRequest) 136 | return 137 | } 138 | epoch := types.Epoch(endEpochValue) 139 | endEpochRequest = &epoch 140 | } 141 | 142 | epochSpanRequest := types.Epoch(DefaultEpochSpanForFaultsWindow) 143 | epochSpanForFaultsWindow := q.Get("window") 144 | if epochSpanForFaultsWindow != "" { 145 | epochSpanValue, err := strconv.ParseUint(epochSpanForFaultsWindow, 10, 64) 146 | if err != nil { 147 | logger.Errorw("error parsing query param for faults request", "err", err, "epochSpan", epochSpanForFaultsWindow) 148 | http.Error(w, err.Error(), http.StatusBadRequest) 149 | return 150 | } 151 | epochSpanRequest = types.Epoch(epochSpanValue) 152 | } 153 | 154 | currentEpoch := s.currentEpoch() 155 | startEpoch, endEpoch := computeSpanFromRequest(startEpochRequest, endEpochRequest, epochSpanRequest, currentEpoch) 156 | faults := s.analyzer.GetFaults(startEpoch, endEpoch) 157 | 158 | w.Header().Set("Content-Type", "application/json") 159 | w.WriteHeader(http.StatusOK) 160 | 161 | response := FaultsResponse{ 162 | Span: Span{ 163 | Start: startEpoch, 164 | End: endEpoch, 165 | }, 166 | FaultRecord: faults, 167 | } 168 | encoder := json.NewEncoder(w) 169 | encoder.SetIndent("", " ") 170 | err := encoder.Encode(response) 171 | if err != nil { 172 | logger.Errorw("could not encode relay faults", "error", err) 173 | http.Error(w, err.Error(), http.StatusInternalServerError) 174 | } 175 | } 176 | 177 | func (s *Server) validateRegistrationTimestamp(registration, currentRegistration *types.SignedValidatorRegistration) error { 178 | timestamp := registration.Message.Timestamp 179 | deadline := time.Now().Add(10 * time.Second).Unix() 180 | if timestamp >= uint64(deadline) { 181 | return fmt.Errorf("invalid registration: too far in future, %+v", registration) 182 | } 183 | 184 | if currentRegistration != nil { 185 | lastTimestamp := currentRegistration.Message.Timestamp 186 | if timestamp < lastTimestamp { 187 | return fmt.Errorf("invalid registration: more recent successful registration, %+v", registration) 188 | } 189 | } 190 | 191 | return nil 192 | } 193 | 194 | func (s *Server) validateRegistrationSignature(registration *types.SignedValidatorRegistration) error { 195 | msg := registration.Message 196 | valid, err := crypto.VerifySignature(msg, s.consensusClient.SignatureDomainForBuilder(), msg.Pubkey[:], registration.Signature[:]) 197 | if err != nil { 198 | return err 199 | } 200 | if !valid { 201 | return fmt.Errorf("signature invalid for validator registration %+v", registration) 202 | } 203 | return nil 204 | } 205 | 206 | func (s *Server) validateRegistrationValidatorStatus(registration *types.SignedValidatorRegistration) error { 207 | publicKey := registration.Message.Pubkey 208 | status, err := s.consensusClient.GetValidatorStatus(&publicKey) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | switch status { 214 | case consensus.StatusValidatorActive, consensus.StatusValidatorPending: 215 | return nil 216 | default: 217 | return fmt.Errorf("invalid registration: validator lifecycle status %s is not `active` or `pending`, %+v", status, registration) 218 | } 219 | } 220 | 221 | func (s *Server) validateRegistration(registration, currentRegistration *types.SignedValidatorRegistration) error { 222 | err := s.validateRegistrationTimestamp(registration, currentRegistration) 223 | if err != nil { 224 | return err 225 | } 226 | 227 | err = s.validateRegistrationSignature(registration) 228 | if err != nil { 229 | return err 230 | } 231 | 232 | err = s.validateRegistrationValidatorStatus(registration) 233 | if err != nil { 234 | return err 235 | } 236 | 237 | return nil 238 | } 239 | 240 | type apiError struct { 241 | Code int `json:"code"` 242 | Message string `json:"message"` 243 | } 244 | 245 | func (s *Server) handleRegisterValidator(w http.ResponseWriter, r *http.Request) { 246 | logger := s.logger.Sugar() 247 | ctx := context.Background() 248 | 249 | var registrations []types.SignedValidatorRegistration 250 | err := json.NewDecoder(r.Body).Decode(®istrations) 251 | if err != nil { 252 | logger.Warn("could not decode signed validator registration") 253 | http.Error(w, err.Error(), http.StatusBadRequest) 254 | return 255 | } 256 | 257 | for _, registration := range registrations { 258 | currentRegistration, err := store.GetLatestValidatorRegistration(ctx, s.store, ®istration.Message.Pubkey) 259 | if err != nil { 260 | logger.Warnw("could not get registrations for validator", "error", err, "registration", registration) 261 | http.Error(w, err.Error(), http.StatusInternalServerError) 262 | return 263 | } 264 | err = s.validateRegistration(®istration, currentRegistration) 265 | if err != nil { 266 | logger.Warnw("invalid validator registration in batch", "registration", registration, "error", err) 267 | w.WriteHeader(http.StatusBadRequest) 268 | w.Header().Set("Content-Type", "application/json") 269 | response := apiError{ 270 | Code: http.StatusBadRequest, 271 | Message: err.Error(), 272 | } 273 | encoder := json.NewEncoder(w) 274 | err := encoder.Encode(response) 275 | if err != nil { 276 | logger.Warnw("could not send API error", "error", err) 277 | http.Error(w, err.Error(), http.StatusInternalServerError) 278 | return 279 | } 280 | return 281 | } 282 | } 283 | 284 | payload := data.ValidatorRegistrationEvent{ 285 | Registrations: registrations, 286 | } 287 | // TODO what if this is full? 288 | s.events <- data.Event{Payload: payload} 289 | 290 | w.WriteHeader(http.StatusOK) 291 | } 292 | 293 | func (s *Server) handleAuctionTranscript(w http.ResponseWriter, r *http.Request) { 294 | logger := s.logger.Sugar() 295 | 296 | var transcript types.AuctionTranscript 297 | err := json.NewDecoder(r.Body).Decode(&transcript) 298 | if err != nil { 299 | logger.Warn("could not decode auction transcript") 300 | http.Error(w, err.Error(), http.StatusBadRequest) 301 | return 302 | } 303 | 304 | logger.Debugw("got auction transcript", "data", transcript) 305 | 306 | payload := data.AuctionTranscriptEvent{ 307 | Transcript: &transcript, 308 | } 309 | // TODO what if this is full? 310 | s.events <- data.Event{Payload: payload} 311 | 312 | w.WriteHeader(http.StatusOK) 313 | } 314 | 315 | func (s *Server) Run(ctx context.Context) error { 316 | logger := s.logger.Sugar() 317 | host := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port) 318 | logger.Infof("API server listening on %s", host) 319 | 320 | mux := http.NewServeMux() 321 | mux.HandleFunc("/", get(s.handleFaultsRequest)) 322 | mux.HandleFunc(GetFaultEndpoint, get(s.handleFaultsRequest)) 323 | mux.HandleFunc(RegisterValidatorEndpoint, post(s.handleRegisterValidator)) 324 | mux.HandleFunc(PostAuctionTranscriptEndpoint, post(s.handleAuctionTranscript)) 325 | return http.ListenAndServe(host, mux) 326 | } 327 | 328 | func get(handler http.HandlerFunc) http.HandlerFunc { 329 | return func(w http.ResponseWriter, r *http.Request) { 330 | switch r.Method { 331 | case "GET": 332 | handler(w, r) 333 | default: 334 | w.WriteHeader(404) 335 | n, err := w.Write([]byte(methodNotSupported)) 336 | if n != len(methodNotSupported) { 337 | http.Error(w, "error writing message", http.StatusInternalServerError) 338 | return 339 | } 340 | if err != nil { 341 | http.Error(w, "error writing message", http.StatusInternalServerError) 342 | return 343 | } 344 | } 345 | } 346 | } 347 | 348 | func post(handler http.HandlerFunc) http.HandlerFunc { 349 | return func(w http.ResponseWriter, r *http.Request) { 350 | switch r.Method { 351 | case "POST": 352 | handler(w, r) 353 | default: 354 | w.WriteHeader(404) 355 | n, err := w.Write([]byte(methodNotSupported)) 356 | if n != len(methodNotSupported) { 357 | http.Error(w, "error writing message", http.StatusInternalServerError) 358 | return 359 | } 360 | if err != nil { 361 | http.Error(w, "error writing message", http.StatusInternalServerError) 362 | return 363 | } 364 | } 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /pkg/builder/client.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | boostTypes "github.com/flashbots/go-boost-utils/types" 11 | "github.com/ralexstokes/relay-monitor/pkg/types" 12 | ) 13 | 14 | const clientTimeoutSec = 2 15 | 16 | type Client struct { 17 | endpoint string 18 | hostname string 19 | PublicKey types.PublicKey 20 | client http.Client 21 | } 22 | 23 | func (c *Client) Hostname() string { 24 | return c.hostname 25 | } 26 | 27 | func (c *Client) String() string { 28 | return c.PublicKey.String() 29 | } 30 | 31 | func NewClient(endpoint string) (*Client, error) { 32 | u, err := url.Parse(endpoint) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | hostname := u.Hostname() 38 | 39 | publicKeyStr := u.User.Username() 40 | var publicKey types.PublicKey 41 | err = publicKey.UnmarshalText([]byte(publicKeyStr)) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | client := http.Client{ 47 | Timeout: clientTimeoutSec * time.Second, 48 | } 49 | return &Client{ 50 | endpoint: endpoint, 51 | hostname: hostname, 52 | PublicKey: publicKey, 53 | client: client, 54 | }, nil 55 | } 56 | 57 | // GetStatus implements the `status` endpoint in the Builder API 58 | func (c *Client) GetStatus() error { 59 | statusUrl := c.endpoint + "/eth/v1/builder/status" 60 | req, err := http.NewRequest(http.MethodGet, statusUrl, nil) 61 | if err != nil { 62 | return err 63 | } 64 | resp, err := c.client.Do(req) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | if resp.StatusCode != http.StatusOK { 70 | return fmt.Errorf("relay status was not healthy with HTTP status code %d", resp.StatusCode) 71 | } 72 | return nil 73 | } 74 | 75 | // GetBid implements the `getHeader` endpoint in the Builder API 76 | // A return value of `(nil, nil)` indicates the relay was reachable but had no bid for the given parameters 77 | func (c *Client) GetBid(slot types.Slot, parentHash types.Hash, publicKey types.PublicKey) (*types.Bid, error) { 78 | bidUrl := c.endpoint + fmt.Sprintf("/eth/v1/builder/header/%d/%s/%s", slot, parentHash, publicKey) 79 | req, err := http.NewRequest(http.MethodGet, bidUrl, nil) 80 | if err != nil { 81 | return nil, err 82 | } 83 | resp, err := c.client.Do(req) 84 | if err != nil { 85 | return nil, err 86 | } 87 | if resp.StatusCode == http.StatusNoContent { 88 | return nil, nil 89 | } 90 | if resp.StatusCode != http.StatusOK { 91 | return nil, fmt.Errorf("failed to get bid with HTTP status code %d", resp.StatusCode) 92 | } 93 | 94 | var bid boostTypes.GetHeaderResponse 95 | err = json.NewDecoder(resp.Body).Decode(&bid) 96 | return bid.Data, err 97 | } 98 | -------------------------------------------------------------------------------- /pkg/builder/client_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ralexstokes/relay-monitor/pkg/builder" 7 | ) 8 | 9 | const ( 10 | exampleRelayURL = "https://0x845bd072b7cd566f02faeb0a4033ce9399e42839ced64e8b2adcfc859ed1e8e1a5a293336a49feac6d9a5edb779be53a@builder-relay-sepolia.flashbots.net" 11 | ) 12 | 13 | func TestClientStatus(t *testing.T) { 14 | c, err := builder.NewClient(exampleRelayURL) 15 | if err != nil { 16 | t.Error(err) 17 | return 18 | } 19 | 20 | err = c.GetStatus() 21 | if err != nil { 22 | t.Error(err) 23 | return 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/consensus/client.go: -------------------------------------------------------------------------------- 1 | package consensus 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "math/big" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/ethereum/go-ethereum/common/math" 15 | lru "github.com/hashicorp/golang-lru" 16 | "github.com/holiman/uint256" 17 | "github.com/protolambda/eth2api" 18 | "github.com/protolambda/eth2api/client/beaconapi" 19 | "github.com/protolambda/eth2api/client/configapi" 20 | "github.com/protolambda/eth2api/client/validatorapi" 21 | "github.com/protolambda/zrnt/eth2/beacon/bellatrix" 22 | "github.com/protolambda/zrnt/eth2/beacon/common" 23 | "github.com/r3labs/sse/v2" 24 | "github.com/ralexstokes/relay-monitor/pkg/crypto" 25 | "github.com/ralexstokes/relay-monitor/pkg/types" 26 | "go.uber.org/zap" 27 | ) 28 | 29 | const ( 30 | clientTimeoutSec = 30 31 | cacheSize = 1024 32 | GasElasticityMultiplier = 2 33 | BaseFeeChangeDenominator uint64 = 8 34 | ) 35 | 36 | var ( 37 | bigZero = big.NewInt(0) 38 | bigOne = big.NewInt(1) 39 | ) 40 | 41 | type ValidatorInfo struct { 42 | publicKey types.PublicKey 43 | index types.ValidatorIndex 44 | } 45 | 46 | type Client struct { 47 | logger *zap.Logger 48 | client *eth2api.Eth2HttpClient 49 | 50 | SlotsPerEpoch uint64 51 | SecondsPerSlot uint64 52 | GenesisTime uint64 53 | genesisForkVersion types.ForkVersion 54 | GenesisValidatorsRoot types.Root 55 | altairForkVersion types.ForkVersion 56 | altairForkEpoch types.Epoch 57 | bellatrixForkVersion types.ForkVersion 58 | bellatrixForkEpoch types.Epoch 59 | 60 | builderSignatureDomain *crypto.Domain 61 | 62 | // slot -> ValidatorInfo 63 | proposerCache *lru.Cache 64 | // slot -> SignedBeaconBlock 65 | blockCache *lru.Cache 66 | // blockNumber -> slot 67 | blockNumberToSlotIndex *lru.Cache 68 | validatorLock sync.RWMutex 69 | // publicKey -> Validator 70 | validatorCache map[types.PublicKey]*eth2api.ValidatorResponse 71 | // validatorIndex -> publicKey, note: points into `validatorCache` 72 | validatorIndexCache map[types.ValidatorIndex]*types.PublicKey 73 | } 74 | 75 | func NewClient(ctx context.Context, endpoint string, logger *zap.Logger) (*Client, error) { 76 | httpClient := ð2api.Eth2HttpClient{ 77 | Addr: endpoint, 78 | Cli: &http.Client{ 79 | Transport: &http.Transport{ 80 | MaxIdleConnsPerHost: 128, 81 | }, 82 | Timeout: clientTimeoutSec * time.Second, 83 | }, 84 | Codec: eth2api.JSONCodec{}, 85 | } 86 | 87 | proposerCache, err := lru.New(cacheSize) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | blockCache, err := lru.New(cacheSize) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | blockNumberToSlotIndex, err := lru.New(cacheSize) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | validatorCache := make(map[types.PublicKey]*eth2api.ValidatorResponse) 103 | validatorIndexCache := make(map[types.ValidatorIndex]*types.PublicKey) 104 | 105 | client := &Client{ 106 | logger: logger, 107 | client: httpClient, 108 | proposerCache: proposerCache, 109 | blockCache: blockCache, 110 | blockNumberToSlotIndex: blockNumberToSlotIndex, 111 | validatorCache: validatorCache, 112 | validatorIndexCache: validatorIndexCache, 113 | } 114 | 115 | err = client.fetchGenesis(ctx) 116 | if err != nil { 117 | logger := client.logger.Sugar() 118 | logger.Fatalf("could not load genesis info: %v", err) 119 | } 120 | 121 | err = client.fetchSpec(ctx) 122 | if err != nil { 123 | logger := client.logger.Sugar() 124 | logger.Fatalf("could not load spec configuration: %v", err) 125 | } 126 | 127 | return client, nil 128 | } 129 | 130 | func (c *Client) SignatureDomainForBuilder() crypto.Domain { 131 | if c.builderSignatureDomain == nil { 132 | domain := crypto.Domain(crypto.ComputeDomain(crypto.DomainTypeAppBuilder, c.genesisForkVersion, types.Root{})) 133 | c.builderSignatureDomain = &domain 134 | } 135 | return *c.builderSignatureDomain 136 | } 137 | 138 | func (c *Client) SignatureDomain(slot types.Slot) crypto.Domain { 139 | forkVersion := c.GetForkVersion(slot) 140 | return crypto.ComputeDomain(crypto.DomainTypeBeaconProposer, forkVersion, c.GenesisValidatorsRoot) 141 | } 142 | 143 | func (c *Client) LoadCurrentContext(ctx context.Context, currentSlot types.Slot, currentEpoch types.Epoch) error { 144 | logger := c.logger.Sugar() 145 | 146 | for i := uint64(0); i < c.SlotsPerEpoch; i++ { 147 | err := c.FetchBlock(ctx, currentSlot-i) 148 | if err != nil { 149 | logger.Warnf("could not fetch latest block for slot %d: %v", currentSlot, err) 150 | } 151 | } 152 | 153 | err := c.FetchProposers(ctx, currentEpoch) 154 | if err != nil { 155 | logger.Warnf("could not load consensus state for epoch %d: %v", currentEpoch, err) 156 | } 157 | 158 | nextEpoch := currentEpoch + 1 159 | err = c.FetchProposers(ctx, nextEpoch) 160 | if err != nil { 161 | logger.Warnf("could not load consensus state for epoch %d: %v", nextEpoch, err) 162 | } 163 | 164 | err = c.FetchValidators(ctx) 165 | if err != nil { 166 | logger.Warnf("could not load validators: %v", err) 167 | } 168 | 169 | return nil 170 | } 171 | 172 | func (c *Client) fetchGenesis(ctx context.Context) error { 173 | var resp eth2api.GenesisResponse 174 | exists, err := beaconapi.Genesis(ctx, c.client, &resp) 175 | if !exists { 176 | return fmt.Errorf("genesis information does not exist") 177 | } 178 | if err != nil { 179 | return err 180 | } 181 | 182 | c.GenesisTime = uint64(resp.GenesisTime) 183 | c.genesisForkVersion = types.ForkVersion(resp.GenesisForkVersion) 184 | c.GenesisValidatorsRoot = types.Hash(resp.GenesisValidatorsRoot) 185 | return nil 186 | } 187 | 188 | func (c *Client) fetchSpec(ctx context.Context) error { 189 | var spec common.Spec 190 | err := configapi.Spec(ctx, c.client, &spec) 191 | if err != nil { 192 | return err 193 | } 194 | 195 | c.SlotsPerEpoch = uint64(spec.Phase0Preset.SLOTS_PER_EPOCH) 196 | c.SecondsPerSlot = uint64(spec.Config.SECONDS_PER_SLOT) 197 | c.altairForkVersion = types.ForkVersion(spec.Config.ALTAIR_FORK_VERSION) 198 | c.altairForkEpoch = types.Epoch(spec.Config.ALTAIR_FORK_EPOCH) 199 | c.bellatrixForkVersion = types.ForkVersion(spec.Config.BELLATRIX_FORK_VERSION) 200 | c.bellatrixForkEpoch = types.Epoch(spec.Config.BELLATRIX_FORK_EPOCH) 201 | return nil 202 | } 203 | 204 | // NOTE: this assumes the fork schedule is presented in ascending order 205 | func (c *Client) GetForkVersion(slot types.Slot) types.ForkVersion { 206 | epoch := slot / c.SlotsPerEpoch 207 | if epoch >= c.bellatrixForkEpoch { 208 | return c.bellatrixForkVersion 209 | } else if epoch >= c.altairForkEpoch { 210 | return c.altairForkVersion 211 | } else { 212 | return c.genesisForkVersion 213 | } 214 | } 215 | 216 | func (c *Client) GetProposer(slot types.Slot) (*ValidatorInfo, error) { 217 | val, ok := c.proposerCache.Get(slot) 218 | if !ok { 219 | return nil, fmt.Errorf("could not find proposer for slot %d", slot) 220 | } 221 | validator, ok := val.(ValidatorInfo) 222 | if !ok { 223 | return nil, fmt.Errorf("internal: proposer cache contains an unexpected type %T", val) 224 | } 225 | return &validator, nil 226 | } 227 | 228 | func (c *Client) GetBlock(slot types.Slot) (*bellatrix.SignedBeaconBlock, error) { 229 | val, ok := c.blockCache.Get(slot) 230 | if !ok { 231 | // TODO pipe in context 232 | err := c.FetchBlock(context.Background(), slot) 233 | if err != nil { 234 | return nil, err 235 | } 236 | val, ok = c.blockCache.Get(slot) 237 | if !ok { 238 | return nil, fmt.Errorf("could not find block for slot %d", slot) 239 | } 240 | } 241 | block, ok := val.(*bellatrix.SignedBeaconBlock) 242 | if !ok { 243 | return nil, fmt.Errorf("internal: block cache contains an unexpected value %v with type %T", val, val) 244 | } 245 | return block, nil 246 | } 247 | 248 | func (c *Client) GetValidator(publicKey *types.PublicKey) (*eth2api.ValidatorResponse, error) { 249 | c.validatorLock.RLock() 250 | defer c.validatorLock.RUnlock() 251 | 252 | validator, ok := c.validatorCache[*publicKey] 253 | if !ok { 254 | return nil, fmt.Errorf("missing validator entry for public key %s", publicKey) 255 | } 256 | return validator, nil 257 | } 258 | 259 | func (c *Client) GetParentHash(ctx context.Context, slot types.Slot) (types.Hash, error) { 260 | targetSlot := slot - 1 261 | block, err := c.GetBlock(targetSlot) 262 | if err != nil { 263 | return types.Hash{}, err 264 | } 265 | return types.Hash(block.Message.Body.ExecutionPayload.BlockHash), nil 266 | } 267 | 268 | func (c *Client) GetProposerPublicKey(ctx context.Context, slot types.Slot) (*types.PublicKey, error) { 269 | validator, err := c.GetProposer(slot) 270 | if err != nil { 271 | // TODO consider fallback to grab the assignments for the missing epoch... 272 | return nil, fmt.Errorf("missing proposer for slot %d: %v", slot, err) 273 | } 274 | return &validator.publicKey, nil 275 | } 276 | 277 | func (c *Client) FetchProposers(ctx context.Context, epoch types.Epoch) error { 278 | var proposerDuties eth2api.DependentProposerDuty 279 | syncing, err := validatorapi.ProposerDuties(ctx, c.client, common.Epoch(epoch), &proposerDuties) 280 | if syncing { 281 | return fmt.Errorf("could not fetch proposal duties in epoch %d because node is syncing", epoch) 282 | } else if err != nil { 283 | return err 284 | } 285 | 286 | // TODO handle reorgs, etc. 287 | for _, duty := range proposerDuties.Data { 288 | c.proposerCache.Add(uint64(duty.Slot), ValidatorInfo{ 289 | publicKey: types.PublicKey(duty.Pubkey), 290 | index: uint64(duty.ValidatorIndex), 291 | }) 292 | } 293 | 294 | return nil 295 | } 296 | 297 | func (c *Client) FetchBlock(ctx context.Context, slot types.Slot) error { 298 | // TODO handle reorgs, etc. 299 | blockID := eth2api.BlockIdSlot(slot) 300 | 301 | var signedBeaconBlock eth2api.VersionedSignedBeaconBlock 302 | exists, err := beaconapi.BlockV2(ctx, c.client, blockID, &signedBeaconBlock) 303 | // NOTE: need to check `exists` first... 304 | if !exists { 305 | return nil 306 | } else if err != nil { 307 | return err 308 | } 309 | 310 | bellatrixBlock, ok := signedBeaconBlock.Data.(*bellatrix.SignedBeaconBlock) 311 | if !ok { 312 | return fmt.Errorf("could not parse block %s", signedBeaconBlock) 313 | } 314 | 315 | c.blockCache.Add(slot, bellatrixBlock) 316 | c.blockNumberToSlotIndex.Add(uint64(bellatrixBlock.Message.Body.ExecutionPayload.BlockNumber), slot) 317 | return nil 318 | } 319 | 320 | type headEvent struct { 321 | Slot string `json:"slot"` 322 | Block types.Root `json:"block"` 323 | } 324 | 325 | func (c *Client) StreamHeads(ctx context.Context) <-chan types.Coordinate { 326 | logger := c.logger.Sugar() 327 | 328 | sseClient := sse.NewClient(c.client.Addr + "/eth/v1/events?topics=head") 329 | ch := make(chan types.Coordinate, 1) 330 | go func() { 331 | err := sseClient.SubscribeRawWithContext(ctx, func(msg *sse.Event) { 332 | var event headEvent 333 | err := json.Unmarshal(msg.Data, &event) 334 | if err != nil { 335 | logger.Warnf("could not unmarshal `head` node event: %v", err) 336 | return 337 | } 338 | slot, err := strconv.Atoi(event.Slot) 339 | if err != nil { 340 | logger.Warnf("could not unmarshal slot from `head` node event: %v", err) 341 | return 342 | } 343 | head := types.Coordinate{ 344 | Slot: types.Slot(slot), 345 | Root: event.Block, 346 | } 347 | ch <- head 348 | }) 349 | if err != nil { 350 | logger.Errorw("could not subscribe to head event", "error", err) 351 | } 352 | }() 353 | return ch 354 | } 355 | 356 | // TODO handle reorgs 357 | func (c *Client) FetchValidators(ctx context.Context) error { 358 | var response []eth2api.ValidatorResponse 359 | exists, err := beaconapi.StateValidators(ctx, c.client, eth2api.StateHead, nil, nil, &response) 360 | if err != nil { 361 | return err 362 | } 363 | if !exists { 364 | return fmt.Errorf("could not fetch validators from remote endpoint because they do not exist") 365 | } 366 | 367 | c.validatorLock.Lock() 368 | defer c.validatorLock.Unlock() 369 | 370 | for _, validator := range response { 371 | publicKey := validator.Validator.Pubkey 372 | key := types.PublicKey(publicKey) 373 | c.validatorCache[key] = &validator 374 | c.validatorIndexCache[uint64(validator.Index)] = &key 375 | } 376 | 377 | return nil 378 | } 379 | 380 | func (c *Client) GetValidatorStatus(publicKey *types.PublicKey) (ValidatorStatus, error) { 381 | validator, err := c.GetValidator(publicKey) 382 | if err != nil { 383 | return StatusValidatorUnknown, err 384 | } 385 | validatorStatus := string(validator.Status) 386 | if strings.Contains(validatorStatus, "active") { 387 | return StatusValidatorActive, nil 388 | } else if strings.Contains(validatorStatus, "pending") { 389 | return StatusValidatorPending, nil 390 | } else { 391 | return StatusValidatorUnknown, nil 392 | } 393 | } 394 | 395 | func (c *Client) GetRandomnessForProposal(slot types.Slot /*, proposerPublicKey *types.PublicKey */) (types.Hash, error) { 396 | targetSlot := slot - 1 397 | // TODO support branches w/ proposer public key 398 | // TODO pipe in context 399 | // TODO or consider getting for each head and caching locally... 400 | return FetchRandao(context.Background(), c.client, targetSlot) 401 | } 402 | 403 | func (c *Client) GetBlockNumberForProposal(slot types.Slot /*, proposerPublicKey *types.PublicKey */) (uint64, error) { 404 | // TODO support branches w/ proposer public key 405 | parentBlock, err := c.GetBlock(slot - 1) 406 | if err != nil { 407 | return 0, err 408 | } 409 | return uint64(parentBlock.Message.Body.ExecutionPayload.BlockNumber) + 1, nil 410 | } 411 | 412 | func computeBaseFee(parentGasTarget, parentGasUsed uint64, parentBaseFee *big.Int) *types.Uint256 { 413 | // NOTE: following the `geth` implementation here: 414 | result := uint256.NewInt(0) 415 | if parentGasUsed == parentGasTarget { 416 | result.SetFromBig(parentBaseFee) 417 | return result 418 | } else if parentGasUsed > parentGasTarget { 419 | x := big.NewInt(int64(parentGasUsed - parentGasTarget)) 420 | y := big.NewInt(int64(parentGasTarget)) 421 | x.Mul(x, parentBaseFee) 422 | x.Div(x, y) 423 | x.Div(x, y.SetUint64(BaseFeeChangeDenominator)) 424 | baseFeeDelta := math.BigMax(x, bigOne) 425 | 426 | x = x.Add(parentBaseFee, baseFeeDelta) 427 | result.SetFromBig(x) 428 | } else { 429 | x := big.NewInt(int64(parentGasTarget - parentGasUsed)) 430 | y := big.NewInt(int64(parentGasTarget)) 431 | x.Mul(x, parentBaseFee) 432 | x.Div(x, y) 433 | x.Div(x, y.SetUint64(BaseFeeChangeDenominator)) 434 | 435 | baseFee := x.Sub(parentBaseFee, x) 436 | result.SetFromBig(math.BigMax(baseFee, bigZero)) 437 | } 438 | return result 439 | } 440 | 441 | func (c *Client) GetBaseFeeForProposal(slot types.Slot /*, proposerPublicKey *types.PublicKey */) (*types.Uint256, error) { 442 | // TODO support multiple branches of block tree 443 | parentBlock, err := c.GetBlock(slot - 1) 444 | if err != nil { 445 | return nil, err 446 | } 447 | parentExecutionPayload := parentBlock.Message.Body.ExecutionPayload 448 | parentGasTarget := uint64(parentExecutionPayload.GasLimit) / GasElasticityMultiplier 449 | parentGasUsed := uint64(parentExecutionPayload.GasUsed) 450 | 451 | parentBaseFee := (uint256.Int)(parentExecutionPayload.BaseFeePerGas) 452 | parentBaseFeeAsInt := parentBaseFee.ToBig() 453 | return computeBaseFee(parentGasTarget, parentGasUsed, parentBaseFeeAsInt), nil 454 | } 455 | 456 | func (c *Client) GetParentGasLimit(ctx context.Context, blockNumber uint64) (uint64, error) { 457 | // TODO support branches w/ proposer public key 458 | slotValue, ok := c.blockNumberToSlotIndex.Get(blockNumber) 459 | if !ok { 460 | return 0, fmt.Errorf("missing block for block number %d", blockNumber) 461 | } 462 | slot, ok := slotValue.(uint64) 463 | if !ok { 464 | return 0, fmt.Errorf("internal: unexpected type %T in block number to slot index", slotValue) 465 | } 466 | parentBlock, err := c.GetBlock(slot - 1) 467 | if err != nil { 468 | return 0, err 469 | } 470 | return uint64(parentBlock.Message.Body.ExecutionPayload.GasLimit), nil 471 | } 472 | 473 | func (c *Client) GetPublicKeyForIndex(ctx context.Context, validatorIndex types.ValidatorIndex) (*types.PublicKey, error) { 474 | c.validatorLock.RLock() 475 | defer c.validatorLock.RUnlock() 476 | 477 | key, ok := c.validatorIndexCache[validatorIndex] 478 | if !ok { 479 | // TODO consider fetching here if not in cache 480 | return nil, fmt.Errorf("could not find public key for validator index %d", validatorIndex) 481 | } 482 | return key, nil 483 | } 484 | -------------------------------------------------------------------------------- /pkg/consensus/client_test.go: -------------------------------------------------------------------------------- 1 | package consensus 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/holiman/uint256" 7 | ) 8 | 9 | func TestComputeBaseF(t *testing.T) { 10 | for _, tc := range []struct { 11 | parentGasTarget uint64 12 | parentGasUsed uint64 13 | baseFeeAsHex string 14 | nextBaseFeeAsHex string 15 | }{ 16 | { 17 | parentGasTarget: 15_000_000, 18 | parentGasUsed: 15_000_000, 19 | baseFeeAsHex: "0x7", 20 | nextBaseFeeAsHex: "0x7", 21 | }, 22 | { 23 | // from geth tests 24 | parentGasTarget: 10000000, 25 | parentGasUsed: 9000000, 26 | baseFeeAsHex: "0x3b9aca00", 27 | nextBaseFeeAsHex: "0x3adc0de0", 28 | }, 29 | { 30 | parentGasTarget: 15_000_000, 31 | parentGasUsed: 20_000_000, 32 | baseFeeAsHex: "0x7", 33 | nextBaseFeeAsHex: "0x8", 34 | }, 35 | } { 36 | baseFeeValue := uint256.NewInt(0) 37 | err := baseFeeValue.UnmarshalText([]byte(tc.baseFeeAsHex)) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | baseFee := baseFeeValue.ToBig() 42 | newBaseFee := computeBaseFee(tc.parentGasTarget, tc.parentGasUsed, baseFee) 43 | 44 | expectedBaseFee := uint256.NewInt(0) 45 | err = expectedBaseFee.UnmarshalText([]byte(tc.nextBaseFeeAsHex)) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | if !newBaseFee.Eq(expectedBaseFee) { 50 | t.Fatal("wrong base fee computed:", newBaseFee, "but expected", expectedBaseFee) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/consensus/clock.go: -------------------------------------------------------------------------------- 1 | package consensus 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/ralexstokes/relay-monitor/pkg/types" 8 | ) 9 | 10 | type Clock struct { 11 | genesisTime uint64 12 | secondsPerSlot uint64 13 | slotsPerEpoch uint64 14 | } 15 | 16 | func NewClock(genesisTime, secondsPerSlot, slotsPerEpoch uint64) *Clock { 17 | return &Clock{ 18 | genesisTime: genesisTime, 19 | secondsPerSlot: secondsPerSlot, 20 | slotsPerEpoch: slotsPerEpoch, 21 | } 22 | } 23 | 24 | func (c *Clock) SlotInSeconds(slot types.Slot) int64 { 25 | return int64(slot*c.secondsPerSlot + c.genesisTime) 26 | } 27 | 28 | func (c *Clock) CurrentSlot(currentTime int64) types.Slot { 29 | diff := currentTime - int64(c.genesisTime) 30 | // TODO better handling of pre-genesis 31 | if diff < 0 { 32 | return 0 33 | } 34 | return types.Slot(diff / int64(c.secondsPerSlot)) 35 | } 36 | 37 | func (c *Clock) EpochForSlot(slot types.Slot) types.Epoch { 38 | return slot / c.slotsPerEpoch 39 | } 40 | 41 | func (c *Clock) TickSlots(ctx context.Context) chan types.Slot { 42 | ch := make(chan types.Slot, 1) 43 | go func() { 44 | for { 45 | now := time.Now().Unix() 46 | currentSlot := c.CurrentSlot(now) 47 | ch <- currentSlot 48 | nextSlot := currentSlot + 1 49 | nextSlotStart := c.SlotInSeconds(nextSlot) 50 | duration := time.Duration(nextSlotStart - now) 51 | select { 52 | case <-time.After(duration * time.Second): 53 | case <-ctx.Done(): 54 | close(ch) 55 | return 56 | } 57 | } 58 | }() 59 | return ch 60 | } 61 | 62 | func (c *Clock) TickEpochs(ctx context.Context) chan types.Epoch { 63 | ch := make(chan types.Epoch, 1) 64 | go func() { 65 | slots := c.TickSlots(ctx) 66 | currentSlot := <-slots 67 | currentEpoch := currentSlot / c.slotsPerEpoch 68 | ch <- currentEpoch 69 | for slot := range slots { 70 | epoch := slot / c.slotsPerEpoch 71 | if epoch > currentEpoch { 72 | currentEpoch = epoch 73 | ch <- currentEpoch 74 | } 75 | } 76 | close(ch) 77 | }() 78 | return ch 79 | } 80 | -------------------------------------------------------------------------------- /pkg/consensus/randao_support.go: -------------------------------------------------------------------------------- 1 | package consensus 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/protolambda/eth2api" 7 | "github.com/protolambda/zrnt/eth2/beacon/common" 8 | "github.com/ralexstokes/relay-monitor/pkg/types" 9 | ) 10 | 11 | // NOTE: code in this file supports the unreleased API to fetch the `randao` value from the beacon state 12 | // See: https://ethereum.github.io/beacon-APIs/?urls.primaryName=dev#/Beacon/getStateRandao 13 | 14 | type RandaoResponse struct { 15 | Randao common.Root `json:"randao"` 16 | } 17 | 18 | func FetchRandao(ctx context.Context, httpClient *eth2api.Eth2HttpClient, slot types.Slot) (types.Hash, error) { 19 | var dest RandaoResponse 20 | _, err := eth2api.SimpleRequest(ctx, httpClient, eth2api.FmtGET("/eth/v1/beacon/states/%d/randao", slot), eth2api.Wrap(&dest)) 21 | return types.Hash(dest.Randao), err 22 | } 23 | -------------------------------------------------------------------------------- /pkg/consensus/validator_status.go: -------------------------------------------------------------------------------- 1 | package consensus 2 | 3 | type ValidatorStatus string 4 | 5 | const ( 6 | StatusValidatorUnknown ValidatorStatus = "unknown" 7 | StatusValidatorActive ValidatorStatus = "active" 8 | StatusValidatorPending ValidatorStatus = "pending" 9 | ) 10 | -------------------------------------------------------------------------------- /pkg/crypto/signing.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import "github.com/flashbots/go-boost-utils/types" 4 | 5 | var ( 6 | VerifySignature = types.VerifySignature 7 | DomainTypeAppBuilder = types.DomainTypeAppBuilder 8 | DomainTypeBeaconProposer = types.DomainTypeBeaconProposer 9 | ComputeDomain = types.ComputeDomain 10 | ) 11 | 12 | type Domain = types.Domain 13 | -------------------------------------------------------------------------------- /pkg/crypto/signing_test.go: -------------------------------------------------------------------------------- 1 | package crypto_test 2 | 3 | import ( 4 | "encoding/binary" 5 | "encoding/json" 6 | "testing" 7 | 8 | boostTypes "github.com/flashbots/go-boost-utils/types" 9 | "github.com/ralexstokes/relay-monitor/pkg/crypto" 10 | "github.com/ralexstokes/relay-monitor/pkg/types" 11 | ) 12 | 13 | const ( 14 | sepoliaSignedValidatorRegistration string = `{"message":{"fee_recipient":"0x1268ad189526ac0b386faf06effc46779c340ee6","gas_limit":"30000000","timestamp":"1667839752","pubkey":"0xb01a30d439def99e676c097e5f4b2aa249aa4d184eaace81819a698cb37d33f5a24089339916ee0acb539f0e62936d83"},"signature":"0xa7000ac532da1f072964c11a058857e3aea87ba29b54ecd743a6e2a46bb05912463cfceace236c37d63b01b7094a0fcc01fbedede160068bceea9c72a77914635c46e810e0e161ebc84f98267c31efab7f920af96442a3cf216935577a77f5b2"}` 15 | ) 16 | 17 | var ( 18 | sepoliaGenesisForkVersion [4]byte = [4]byte{0x90, 0x00, 0x00, 0x69} 19 | sepoliaGenesisForkVersionAsNumber uint32 = 2415919209 20 | ) 21 | 22 | func TestCanComputeDomain(t *testing.T) { 23 | genesisForkVersion := [4]byte{} 24 | binary.BigEndian.PutUint32(genesisForkVersion[0:4], sepoliaGenesisForkVersionAsNumber) 25 | domain := boostTypes.ComputeDomain(boostTypes.DomainTypeAppBuilder, genesisForkVersion, types.Root{}) 26 | correctDomain := boostTypes.ComputeDomain(boostTypes.DomainTypeAppBuilder, sepoliaGenesisForkVersion, types.Root{}) 27 | if domain != correctDomain { 28 | t.Fatal("could not compute correct domain") 29 | } 30 | } 31 | 32 | func TestSignatureVerification(t *testing.T) { 33 | var registration types.SignedValidatorRegistration 34 | err := json.Unmarshal([]byte(sepoliaSignedValidatorRegistration), ®istration) 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | genesisForkVersion := [4]byte{} 40 | binary.BigEndian.PutUint32(genesisForkVersion[0:4], sepoliaGenesisForkVersionAsNumber) 41 | domain := boostTypes.ComputeDomain(boostTypes.DomainTypeAppBuilder, genesisForkVersion, types.Root{}) 42 | 43 | valid, err := crypto.VerifySignature(registration.Message, domain, registration.Message.Pubkey[:], registration.Signature[:]) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | if !valid { 48 | t.Fatal("signature did not verify") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/data/collector.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ralexstokes/relay-monitor/pkg/builder" 7 | "github.com/ralexstokes/relay-monitor/pkg/consensus" 8 | "github.com/ralexstokes/relay-monitor/pkg/types" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | type Collector struct { 13 | logger *zap.Logger 14 | relays []*builder.Client 15 | clock *consensus.Clock 16 | consensusClient *consensus.Client 17 | events chan<- Event 18 | } 19 | 20 | func NewCollector(zapLogger *zap.Logger, relays []*builder.Client, clock *consensus.Clock, consensusClient *consensus.Client, events chan<- Event) *Collector { 21 | return &Collector{ 22 | logger: zapLogger, 23 | relays: relays, 24 | clock: clock, 25 | consensusClient: consensusClient, 26 | events: events, 27 | } 28 | } 29 | 30 | func (c *Collector) collectBidFromRelay(ctx context.Context, relay *builder.Client, slot types.Slot) (*BidEvent, error) { 31 | parentHash, err := c.consensusClient.GetParentHash(ctx, slot) 32 | if err != nil { 33 | return nil, err 34 | } 35 | publicKey, err := c.consensusClient.GetProposerPublicKey(ctx, slot) 36 | if err != nil { 37 | return nil, err 38 | } 39 | bid, err := relay.GetBid(slot, parentHash, *publicKey) 40 | if err != nil { 41 | return nil, err 42 | } 43 | if bid == nil { 44 | return nil, nil 45 | } 46 | bidCtx := types.BidContext{ 47 | Slot: slot, 48 | ParentHash: parentHash, 49 | ProposerPublicKey: *publicKey, 50 | RelayPublicKey: relay.PublicKey, 51 | } 52 | event := &BidEvent{Context: &bidCtx, Bid: bid} 53 | return event, nil 54 | } 55 | 56 | func (c *Collector) collectFromRelay(ctx context.Context, relay *builder.Client) { 57 | logger := c.logger.Sugar() 58 | 59 | relayID := relay.PublicKey 60 | 61 | slots := c.clock.TickSlots(ctx) 62 | for { 63 | select { 64 | case <-ctx.Done(): 65 | return 66 | case slot := <-slots: 67 | payload, err := c.collectBidFromRelay(ctx, relay, slot) 68 | if err != nil { 69 | logger.Warnw("could not get bid from relay", "error", err, "relayPublicKey", relayID, "slot", slot) 70 | // TODO implement some retry logic... 71 | continue 72 | } 73 | if payload == nil { 74 | // No bid for this slot, continue 75 | // TODO consider trying again... 76 | continue 77 | } 78 | logger.Debugw("got bid", "relay", relayID, "context", payload.Context, "bid", payload.Bid) 79 | // TODO what if this is slow 80 | c.events <- Event{Payload: payload} 81 | } 82 | } 83 | } 84 | 85 | func (c *Collector) syncBlocks(ctx context.Context) { 86 | logger := c.logger.Sugar() 87 | 88 | heads := c.consensusClient.StreamHeads(ctx) 89 | for { 90 | select { 91 | case <-ctx.Done(): 92 | return 93 | case head := <-heads: 94 | err := c.consensusClient.FetchBlock(ctx, head.Slot) 95 | if err != nil { 96 | logger.Warnf("could not fetch latest execution hash for slot %d: %v", head.Slot, err) 97 | } 98 | } 99 | } 100 | } 101 | 102 | func (c *Collector) syncProposers(ctx context.Context) { 103 | logger := c.logger.Sugar() 104 | 105 | epochs := c.clock.TickEpochs(ctx) 106 | for { 107 | select { 108 | case <-ctx.Done(): 109 | return 110 | case epoch := <-epochs: 111 | err := c.consensusClient.FetchProposers(ctx, epoch+1) 112 | if err != nil { 113 | logger.Warnf("could not load consensus state for epoch %d: %v", epoch, err) 114 | } 115 | } 116 | } 117 | } 118 | 119 | func (c *Collector) syncValidators(ctx context.Context) { 120 | logger := c.logger.Sugar() 121 | 122 | epochs := c.clock.TickEpochs(ctx) 123 | for { 124 | select { 125 | case <-ctx.Done(): 126 | return 127 | case epoch := <-epochs: 128 | err := c.consensusClient.FetchValidators(ctx) 129 | if err != nil { 130 | logger.Warnf("could not load validators in epoch %d: %v", epoch, err) 131 | } 132 | } 133 | } 134 | } 135 | 136 | // TODO refactor this into a separate component as the list of duties is growing outside the "collector" abstraction 137 | func (c *Collector) collectConsensusData(ctx context.Context) { 138 | go c.syncBlocks(ctx) 139 | go c.syncProposers(ctx) 140 | go c.syncValidators(ctx) 141 | } 142 | 143 | func (c *Collector) Run(ctx context.Context) error { 144 | logger := c.logger.Sugar() 145 | 146 | for _, relay := range c.relays { 147 | relayID := relay.PublicKey 148 | logger.Infof("monitoring relay %s", relayID) 149 | 150 | go c.collectFromRelay(ctx, relay) 151 | } 152 | go c.collectConsensusData(ctx) 153 | 154 | <-ctx.Done() 155 | return nil 156 | } 157 | -------------------------------------------------------------------------------- /pkg/data/event.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "github.com/ralexstokes/relay-monitor/pkg/types" 4 | 5 | type Event struct { 6 | Payload any 7 | } 8 | 9 | type BidEvent struct { 10 | Context *types.BidContext 11 | // A `nil` `Bid` indicates absence for the given `Context` 12 | Bid *types.Bid 13 | } 14 | 15 | type ValidatorRegistrationEvent struct { 16 | Registrations []types.SignedValidatorRegistration 17 | } 18 | 19 | type AuctionTranscriptEvent struct { 20 | Transcript *types.AuctionTranscript 21 | } 22 | -------------------------------------------------------------------------------- /pkg/monitor/config.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "github.com/ralexstokes/relay-monitor/pkg/api" 5 | ) 6 | 7 | type NetworkConfig struct { 8 | Name string `yaml:"name"` 9 | } 10 | 11 | type ConsensusConfig struct { 12 | Endpoint string `yaml:"endpoint"` 13 | } 14 | 15 | type Config struct { 16 | Network *NetworkConfig `yaml:"network"` 17 | Consensus *ConsensusConfig `yaml:"consensus"` 18 | Relays []string `yaml:"relays"` 19 | Api *api.Config `yaml:"api"` 20 | } 21 | -------------------------------------------------------------------------------- /pkg/monitor/monitor.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/ralexstokes/relay-monitor/pkg/analysis" 9 | "github.com/ralexstokes/relay-monitor/pkg/api" 10 | "github.com/ralexstokes/relay-monitor/pkg/builder" 11 | "github.com/ralexstokes/relay-monitor/pkg/consensus" 12 | "github.com/ralexstokes/relay-monitor/pkg/data" 13 | "github.com/ralexstokes/relay-monitor/pkg/store" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | const eventBufferSize uint = 32 18 | 19 | type Monitor struct { 20 | logger *zap.Logger 21 | 22 | api *api.Server 23 | collector *data.Collector 24 | analyzer *analysis.Analyzer 25 | } 26 | 27 | func parseRelaysFromEndpoint(logger *zap.SugaredLogger, relayEndpoints []string) []*builder.Client { 28 | var relays []*builder.Client 29 | for _, endpoint := range relayEndpoints { 30 | relay, err := builder.NewClient(endpoint) 31 | if err != nil { 32 | logger.Warnf("could not instantiate relay at %s: %v", endpoint, err) 33 | continue 34 | } 35 | 36 | err = relay.GetStatus() 37 | if err != nil { 38 | logger.Warnf("relay %s has status error: %v", endpoint, err) 39 | continue 40 | } 41 | 42 | relays = append(relays, relay) 43 | } 44 | if len(relays) == 0 { 45 | logger.Warn("could not parse any relays, please check configuration") 46 | } 47 | return relays 48 | } 49 | 50 | func New(ctx context.Context, config *Config, zapLogger *zap.Logger) (*Monitor, error) { 51 | logger := zapLogger.Sugar() 52 | 53 | relays := parseRelaysFromEndpoint(logger, config.Relays) 54 | 55 | consensusClient, err := consensus.NewClient(ctx, config.Consensus.Endpoint, zapLogger) 56 | if err != nil { 57 | return nil, fmt.Errorf("could not instantiate consensus client: %v", err) 58 | } 59 | 60 | clock := consensus.NewClock(consensusClient.GenesisTime, consensusClient.SecondsPerSlot, consensusClient.SlotsPerEpoch) 61 | now := time.Now().Unix() 62 | currentSlot := clock.CurrentSlot(now) 63 | currentEpoch := clock.EpochForSlot(currentSlot) 64 | 65 | err = consensusClient.LoadCurrentContext(ctx, currentSlot, currentEpoch) 66 | if err != nil { 67 | logger.Warn("could not load the current context from the consensus client") 68 | } 69 | 70 | events := make(chan data.Event, eventBufferSize) 71 | collector := data.NewCollector(zapLogger, relays, clock, consensusClient, events) 72 | store := store.NewMemoryStore() 73 | analyzer := analysis.NewAnalyzer(zapLogger, relays, events, store, consensusClient, clock) 74 | 75 | apiServer := api.New(config.Api, zapLogger, analyzer, events, clock, store, consensusClient) 76 | return &Monitor{ 77 | logger: zapLogger, 78 | api: apiServer, 79 | collector: collector, 80 | analyzer: analyzer, 81 | }, nil 82 | } 83 | 84 | func (s *Monitor) Run(ctx context.Context) { 85 | logger := s.logger.Sugar() 86 | 87 | go func() { 88 | err := s.collector.Run(ctx) 89 | if err != nil { 90 | logger.Warn("error running collector: %v", err) 91 | } 92 | }() 93 | go func() { 94 | err := s.analyzer.Run(ctx) 95 | if err != nil { 96 | logger.Warn("error running collector: %v", err) 97 | } 98 | }() 99 | 100 | err := s.api.Run(ctx) 101 | if err != nil { 102 | logger.Warn("error running API server: %v", err) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ralexstokes/relay-monitor/pkg/types" 8 | ) 9 | 10 | type Storer interface { 11 | PutBid(context.Context, *types.BidContext, *types.Bid) error 12 | PutValidatorRegistration(context.Context, *types.SignedValidatorRegistration) error 13 | PutAcceptance(context.Context, *types.BidContext, *types.SignedBlindedBeaconBlock) error 14 | 15 | GetBid(context.Context, *types.BidContext) (*types.Bid, error) 16 | // `GetValidatorRegistrations` returns all known registrations for the validator's public key, sorted by timestamp (increasing). 17 | GetValidatorRegistrations(context.Context, *types.PublicKey) ([]types.SignedValidatorRegistration, error) 18 | } 19 | 20 | type MemoryStore struct { 21 | bids map[types.BidContext]*types.Bid 22 | registrations map[types.PublicKey][]types.SignedValidatorRegistration 23 | acceptances map[types.BidContext]types.SignedBlindedBeaconBlock 24 | } 25 | 26 | func NewMemoryStore() *MemoryStore { 27 | return &MemoryStore{ 28 | bids: make(map[types.BidContext]*types.Bid), 29 | registrations: make(map[types.PublicKey][]types.SignedValidatorRegistration), 30 | acceptances: make(map[types.BidContext]types.SignedBlindedBeaconBlock), 31 | } 32 | } 33 | 34 | func (s *MemoryStore) PutBid(ctx context.Context, bidCtx *types.BidContext, bid *types.Bid) error { 35 | s.bids[*bidCtx] = bid 36 | return nil 37 | } 38 | 39 | func (s *MemoryStore) GetBid(ctx context.Context, bidCtx *types.BidContext) (*types.Bid, error) { 40 | bid, ok := s.bids[*bidCtx] 41 | if !ok { 42 | return nil, fmt.Errorf("could not find bid for %+v", bidCtx) 43 | } 44 | return bid, nil 45 | } 46 | 47 | func (s *MemoryStore) PutValidatorRegistration(ctx context.Context, registration *types.SignedValidatorRegistration) error { 48 | publicKey := registration.Message.Pubkey 49 | registrations := s.registrations[publicKey] 50 | registrations = append(registrations, *registration) 51 | s.registrations[publicKey] = registrations 52 | return nil 53 | } 54 | 55 | func (s *MemoryStore) PutAcceptance(ctx context.Context, bidCtx *types.BidContext, acceptance *types.SignedBlindedBeaconBlock) error { 56 | s.acceptances[*bidCtx] = *acceptance 57 | return nil 58 | } 59 | 60 | func (s *MemoryStore) GetValidatorRegistrations(ctx context.Context, publicKey *types.PublicKey) ([]types.SignedValidatorRegistration, error) { 61 | return s.registrations[*publicKey], nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/store/util.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ralexstokes/relay-monitor/pkg/types" 7 | ) 8 | 9 | func GetLatestValidatorRegistration(ctx context.Context, store Storer, publicKey *types.PublicKey) (*types.SignedValidatorRegistration, error) { 10 | registrations, err := store.GetValidatorRegistrations(ctx, publicKey) 11 | if err != nil { 12 | return nil, err 13 | } 14 | 15 | if len(registrations) == 0 { 16 | return nil, nil 17 | } else { 18 | currentRegistration := ®istrations[len(registrations)-1] 19 | return currentRegistration, nil 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pkg/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/flashbots/go-boost-utils/types" 5 | "github.com/holiman/uint256" 6 | ) 7 | 8 | type ( 9 | Slot = uint64 10 | Epoch = uint64 11 | ForkVersion = types.ForkVersion 12 | Uint256 = uint256.Int 13 | PublicKey = types.PublicKey 14 | Hash = types.Hash 15 | Bid = types.SignedBuilderBid 16 | Root = types.Root 17 | ValidatorIndex = uint64 18 | SignedValidatorRegistration = types.SignedValidatorRegistration 19 | SignedBlindedBeaconBlock = types.SignedBlindedBeaconBlock 20 | ) 21 | 22 | type Coordinate struct { 23 | Slot Slot 24 | Root Root 25 | } 26 | 27 | type AuctionTranscript struct { 28 | Bid Bid `json:"bid"` 29 | Acceptance types.SignedBlindedBeaconBlock `json:"acceptance"` 30 | } 31 | 32 | type BidContext struct { 33 | Slot Slot `json:"slot"` 34 | ParentHash Hash `json:"parent_hash"` 35 | ProposerPublicKey PublicKey `json:"proposer_public_key"` 36 | RelayPublicKey PublicKey `json:"relay_public_key"` 37 | } 38 | --------------------------------------------------------------------------------