├── .dockerignore ├── .github └── workflows │ ├── docker.yaml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── build.py ├── charts └── eth-validator-watcher │ ├── Chart.yaml │ ├── README.md │ ├── templates │ ├── _helpers.tpl │ ├── configmap.yaml │ ├── deployment.yaml │ ├── podmonitor.yaml │ └── serviceaccount.yaml │ └── values.yaml ├── docs └── img │ ├── Kiln_Logo-Transparent-Dark.svg │ ├── watcher-breakdown.png │ └── watcher-overview.png ├── etc ├── .gitignore └── config.local.yaml ├── eth_validator_watcher ├── __init__.py ├── beacon.py ├── blocks.py ├── clock.py ├── coinbase.py ├── config.py ├── duties.py ├── entrypoint.py ├── log.py ├── metrics.py ├── mod.cc ├── models.py ├── proposer_schedule.py ├── queues.py ├── rewards.py ├── utils.py └── watched_validators.py ├── grafana ├── dashboard-breakdown.json └── dashboard-overview.json ├── justfile ├── mkdocs.yml ├── pyproject.toml ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── assets ├── __init__.py ├── cassettes │ └── test_sepolia.yaml ├── config.empty.yaml ├── config.null.yaml ├── config.sepolia.yaml ├── config.sepolia_replay_2_slots.yaml ├── config.sepolia_replay_5_slots.yaml ├── config.yaml └── sepolia_header_4996301.json ├── test_beacon.py ├── test_config.py ├── test_hexbits.py └── test_sepolia.py /.dockerignore: -------------------------------------------------------------------------------- 1 | etc/ 2 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | paths: 7 | - 'eth_validator_watcher/*.py' 8 | - 'Dockerfile' 9 | - 'pyproject.toml' 10 | - ".github/workflows/docker.yaml" 11 | tags: 12 | - "v*" 13 | pull_request: 14 | branches: ["main"] 15 | paths: 16 | - 'eth_validator_watcher/*.py' 17 | - 'Dockerfile' 18 | - 'pyproject.toml' 19 | - ".github/workflows/docker.yaml" 20 | 21 | permissions: 22 | contents: read 23 | packages: write 24 | 25 | jobs: 26 | docker-build-push: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: "git:checkout" 30 | uses: actions/checkout@v3 31 | with: 32 | fetch-depth: 0 33 | - name: "docker:meta" 34 | id: meta 35 | uses: docker/metadata-action@v4 36 | with: 37 | images: ghcr.io/${{ github.repository }} 38 | flavor: latest=true 39 | tags: | 40 | type=ref,event=branch 41 | type=ref,event=pr 42 | type=semver,pattern={{version}} 43 | - name: "docker:login:ghcr.io" 44 | if: github.ref_type == 'tag' 45 | uses: docker/login-action@v2 46 | with: 47 | registry: ghcr.io 48 | username: ${{ github.actor }} 49 | password: ${{ secrets.GITHUB_TOKEN }} 50 | - name: "docker:buildx" 51 | uses: docker/setup-buildx-action@v2 52 | - name: "docker:build-push" 53 | uses: docker/build-push-action@v4 54 | with: 55 | context: . 56 | file: Dockerfile 57 | push: ${{ github.ref_type == 'tag' }} 58 | tags: ${{ steps.meta.outputs.tags }} 59 | labels: ${{ steps.meta.outputs.labels }} 60 | platforms: linux/amd64,linux/arm64 61 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | name: Run tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python 3.12 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: 3.12 19 | - name: Install uv 20 | uses: astral-sh/setup-uv@v5 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version-file: "pyproject.toml" 25 | - name: Install the project 26 | run: uv sync --all-extras --dev 27 | - name: Run tests 28 | run: uv run pytest --cov eth_validator_watcher --cov-report xml 29 | - name: Upload coverage to Codecov 30 | uses: codecov/codecov-action@v3 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # UV package manager 2 | .uv/ 3 | uv.lock 4 | 5 | # Python bytecode 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # Distribution / packaging 11 | dist/ 12 | build/ 13 | *.egg-info/ 14 | 15 | # Virtual environments 16 | .env/ 17 | .venv/ 18 | env/ 19 | venv/ 20 | ENV/ 21 | 22 | # Testing 23 | .pytest_cache/ 24 | .coverage 25 | htmlcov/ 26 | 27 | # IDE specific files 28 | .idea/ 29 | .vscode/ 30 | *.swp 31 | *.swo 32 | 33 | # OS specific 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Pybind specific 38 | *.so 39 | 40 | # Docs 41 | site/ 42 | 43 | # Agent 44 | CLAUDE.md 45 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.2.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude: ^docs/ 7 | - id: end-of-file-fixer 8 | exclude: ^docs/ 9 | - id: check-added-large-files 10 | exclude: ^docs/ 11 | - id: check-yaml 12 | exclude: ^charts/ 13 | - repo: https://github.com/norwoodj/helm-docs 14 | rev: v1.11.0 15 | hooks: 16 | - id: helm-docs 17 | args: 18 | # Make the tool search for charts only under the `example-charts` directory 19 | - --chart-search-root=charts 20 | 21 | # The `./` makes it relative to the chart-search-root set above 22 | - --template-files=./_templates.gotmpl 23 | 24 | # Repeating the flag adds this to the list, now [./_templates.gotmpl, README.md.gotmpl] 25 | # A base filename makes it relative to each chart directory found 26 | - --template-files=README.md.gotmpl 27 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: helm-docs 2 | args: [] 3 | description: Uses 'helm-docs' to create documentation from the Helm chart's 'values.yaml' file, and inserts the result into a corresponding 'README.md' file. 4 | entry: git-hook/helm-docs 5 | files: (README\.md\.gotmpl|(Chart|requirements|values)\.yaml)$ 6 | language: script 7 | name: Helm Docs 8 | require_serial: true 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-bookworm as builder 2 | 3 | RUN pip install uv 4 | 5 | WORKDIR /app 6 | 7 | COPY eth_validator_watcher /app/eth_validator_watcher 8 | COPY pyproject.toml /app/pyproject.toml 9 | COPY setup.py /app/setup.py 10 | COPY README.md /app/README.md 11 | COPY tests /app/tests 12 | COPY build.py /app/build.py 13 | 14 | RUN uv venv /virtualenv && . /virtualenv/bin/activate && uv pip install -e . 15 | 16 | FROM python:3.12-slim-bookworm 17 | 18 | COPY --from=builder /virtualenv /virtualenv 19 | COPY --from=builder /app /app 20 | ENV PATH="/virtualenv/bin:$PATH" 21 | 22 | WORKDIR /app 23 | 24 | ENTRYPOINT [ "eth-validator-watcher" ] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kiln 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ethereum Validator Watcher 2 | 3 | ![kiln-logo](docs/img/Kiln_Logo-Transparent-Dark.svg) 4 | 5 | [![License](https://img.shields.io/badge/license-MIT-blue)](https://opensource.org/licenses/MIT) 6 | 7 | The code is provided as-is with no warranties. 8 | 9 | - Youtube video of [Ethereum Validator Watcher talk during EthCC[6]](https://www.youtube.com/watch?v=SkyncLrME1g&t=12s&ab_channel=%5BEthCC%5DLivestream2) 10 | - Youtube video of [Ethereum Validator Watcher talk during EthStaker](https://www.youtube.com/watch?v=JrGz5FROgEg) 11 | 12 | ## Overview Dashboard 13 | 14 | The [overview dashboard](grafana/dashboard-overview.json) shows an 15 | overview of the entire set of watched keys and how they relate to the 16 | rest of the network (asset under management, state of keys, 17 | performances): 18 | 19 | ![overview-dashboard](docs/img/watcher-overview.png) 20 | 21 | ## Breakdown Dashboard 22 | 23 | The [breakdown dashboard](grafana/dashboard-breakdown.json) offers a 24 | way to compare how each set of keys in user-defined category 25 | perform: 26 | 27 | ![breakdown-dashboard](docs/img/watcher-breakdown.png) 28 | 29 | ## Command line options 30 | 31 | ``` 32 | Usage: eth-validator-watcher [OPTIONS] 33 | 34 | Run the Ethereum Validator Watcher. 35 | 36 | Options: 37 | --config FILE File containing the Ethereum Validator Watcher configuration 38 | file. [default: etc/config.local.yaml] 39 | --help Show this message and exit. 40 | ``` 41 | 42 | ## Configuration 43 | 44 | The configuration uses the YAML format: 45 | 46 | ```yaml 47 | # Example config file for the Ethereum validator watcher. 48 | 49 | beacon_url: http://localhost:5051/ 50 | beacon_timeout_sec: 90 51 | network: mainnet 52 | metrics_port: 8000 53 | 54 | watched_keys: 55 | - public_key: '0xa1d1ad0714035353258038e964ae9675dc0252ee22cea896825c01458e1807bfad2f9969338798548d9858a571f7425c' 56 | labels: ["vc:validator-1", "region:sbg"] 57 | - public_key: '0x8619c074f403637fdc1f49b77fc295c30214ed3060573a1bfd24caea1f25f7b8e6a9076b7c721076d807003c87956dc1' 58 | labels: ["vc:validator-1", "region:sbg"] 59 | - public_key: '0x91c44564d1e61f7f6e35c330bd931590036d461378ab260b83e77f012a47605a393b5a375bf591466b274dad0b0e8a25' 60 | labels: ["vc:validator-2", "region:rbx"] 61 | ``` 62 | 63 | In this example, we define 3 validators which are running on two 64 | validator clients in separate regions. The labels can be anything you 65 | want as long as it follows the `category:value` format. The 66 | [breakdown dashboard](docs/img/watcher-breakdown.png) uses it to offer 67 | per-value comparisons within a category. You can for instance compare your 68 | missed attestations between region `rbx` and `sbg`, or between `validator-1` 69 | and `validator-2`. This comes handy when operating at scale, you can 70 | quickly isolate where an issue comes from if your groups match your 71 | infrastructure. 72 | 73 | Any categories of labels is possible, some plausible examples: 74 | 75 | - by beacon instance (i.e: beacon:beacon-1) 76 | - by client version (i.e: prysm:v5.0.3) 77 | - by cluster (i.e: cluster:baremetal-1) 78 | - by operator (i.e: operator:kiln) 79 | 80 | By default, the watcher exports the following labels: 81 | 82 | - `scope:watched` for the keys present in the configuration file, 83 | - `scope:network` for the entire network without the keys in the configuration file, 84 | - `scope:all-network` for the entire network including the watched keys. 85 | 86 | Those are used by the overview dashboard and the breakdown dashboard 87 | to offer a comparison of your validator keys with the network. 88 | 89 | The configuration can be updated in real-time, the watcher will reload 90 | it dynamically on the next epoch. This allows to have growing sets of 91 | validators, for instance if you deploy new keys. 92 | 93 | ## Beacon Compatibility 94 | 95 | Beacon type | Compatibility 96 | -----------------|------------------ 97 | Lighthouse | Full. 98 | Prysm | Full. 99 | Teku | Not (yet) tested. 100 | Nimbus | Not (yet) tested. 101 | Lodestar | Not (yet) tested. 102 | 103 | The beacon type is relative to the beacon node connected to the 104 | watcher, **not to the beacon node connected to the validator client 105 | containing a validator key you want to watch**. The watcher is 106 | agnostic of the infrastructure mananing validators keys you want to 107 | watch, this means you can run it on an external location if you want 108 | blackbox monitoring. 109 | 110 | ## Installation 111 | 112 | From source: 113 | 114 | ``` 115 | git clone git@github.com:kilnfi/eth-validator-watcher.git 116 | cd eth-validator-watcher 117 | pip install . 118 | ``` 119 | 120 | Or with uv: 121 | 122 | ``` 123 | git clone git@github.com:kilnfi/eth-validator-watcher.git 124 | cd eth-validator-watcher 125 | uv venv 126 | source .venv/bin/activate 127 | uv pip install . 128 | ``` 129 | 130 | We recommend using the Docker images. 131 | 132 | ## Docker images 133 | 134 | Docker images (built for AMD64 and ARM64) are available 135 | [here](https://github.com/kilnfi/eth-validator-watcher/pkgs/container/eth-validator-watcher). 136 | 137 | ## Developer guide 138 | 139 | We use [uv](https://github.com/astral-sh/uv) to manage dependencies and packaging. 140 | 141 | **Installation:** 142 | 143 | ``` 144 | git clone git@github.com:kilnfi/validator-watcher.git 145 | cd validator-watcher 146 | uv venv 147 | source .venv/bin/activate 148 | uv pip install -e ".[dev]" 149 | ``` 150 | 151 | **Running tests:** 152 | 153 | ``` 154 | source .venv/bin/activate 155 | just test 156 | ``` 157 | 158 | **Running linter:** 159 | 160 | ``` 161 | source .venv/bin/activate 162 | just lint 163 | ``` 164 | 165 | ## License 166 | 167 | [MIT License](LICENSE). -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | from pybind11.setup_helpers import Pybind11Extension, build_ext 2 | 3 | 4 | def build(setup_kwargs): 5 | ext_modules = [ 6 | Pybind11Extension( 7 | "eth_validator_watcher_ext", [ 8 | "eth_validator_watcher/mod.cc" 9 | ], 10 | extra_compile_args=['-O3', '-pthread'], 11 | language='c++', 12 | cxx_std=17 13 | ) 14 | ] 15 | setup_kwargs.update({ 16 | "ext_modules": ext_modules, 17 | "cmd_class": {"build_ext": build_ext}, 18 | "zip_safe": False, 19 | }) 20 | -------------------------------------------------------------------------------- /charts/eth-validator-watcher/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: ethereum-validator-watcher 3 | description: A Helm chart to deploy ethereum-validator-watcher on Kubernetes 4 | type: application 5 | version: 2.0.0 6 | appVersion: mxs-0.0.8-beta1 7 | maintainers: 8 | - name: kiln 9 | email: contact@kiln.fi 10 | -------------------------------------------------------------------------------- /charts/eth-validator-watcher/README.md: -------------------------------------------------------------------------------- 1 | # ethereum-validator-watcher 2 | 3 | ![Version: 6.0.1](https://img.shields.io/badge/Version-6.0.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: mxs-0.0.8-beta1](https://img.shields.io/badge/AppVersion-mxs--0.0.8--beta1-informational?style=flat-square) 4 | 5 | A Helm chart to deploy ethereum-validator-watcher on Kubernetes 6 | 7 | ## Maintainers 8 | 9 | | Name | Email | Url | 10 | | ---- | ------ | --- | 11 | | kiln | | | 12 | 13 | ## Values 14 | 15 | | Key | Type | Default | Description | 16 | |-----|------|---------|-------------| 17 | | affinity | object | `{}` | | 18 | | config.beacon_timeout_sec | int | `90` | | 19 | | config.beacon_url | string | `"http://beacon-url:5051"` | | 20 | | config.metrics_port | int | `8000` | | 21 | | config.network | string | `"network-name"` | | 22 | | config.watched_keys[0].labels[0] | string | `"operator:kiln"` | | 23 | | config.watched_keys[0].labels[1] | string | `"vc:prysm-validator-1"` | | 24 | | config.watched_keys[0].public_key[0] | string | `"989fa046d04b41fc95a04dabb7ab8b64e84afaa85c0aa49e1c6878d7b2814094402d62ae42dfbf3ac72e6770ee0926a8"` | | 25 | | deploymentAnnotations | object | `{}` | | 26 | | extraArgs | object | `{}` | | 27 | | fullnameOverride | string | `""` | | 28 | | image.pullPolicy | string | `"IfNotPresent"` | | 29 | | image.repository | string | `"ghcr.io/kilnfi/eth-validator-watcher"` | | 30 | | image.tag | string | `"1.0.0-beta8"` | | 31 | | livenessProbe.failureThreshold | int | `10` | | 32 | | livenessProbe.httpGet.path | string | `"/"` | | 33 | | livenessProbe.httpGet.port | int | `8000` | | 34 | | livenessProbe.initialDelaySeconds | int | `600` | | 35 | | livenessProbe.periodSeconds | int | `60` | | 36 | | livenessProbe.timeoutSeconds | int | `60` | | 37 | | nameOverride | string | `""` | | 38 | | nodeSelector | object | `{}` | | 39 | | podAnnotations | object | `{}` | | 40 | | podLabels | object | `{}` | | 41 | | podMonitor.additionalLabels | object | `{}` | | 42 | | podMonitor.enabled | bool | `true` | | 43 | | podMonitor.interval | string | `"12s"` | | 44 | | podMonitor.relabelings | list | `[]` | | 45 | | podMonitor.scheme | string | `"http"` | | 46 | | podMonitor.tlsConfig | object | `{}` | | 47 | | podSecurityContext | object | `{}` | | 48 | | readinessProbe | object | `{}` | | 49 | | replicaCount | int | `1` | | 50 | | resources | object | `{}` | | 51 | | securityContext | object | `{}` | | 52 | | serviceAccount.annotations | object | `{}` | | 53 | | serviceAccount.create | bool | `true` | | 54 | | serviceAccount.labels | object | `{}` | | 55 | | serviceAccount.name | string | `""` | | 56 | | startupProbe | object | `{}` | | 57 | | tolerations | list | `[]` | | 58 | 59 | ---------------------------------------------- 60 | Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) 61 | -------------------------------------------------------------------------------- /charts/eth-validator-watcher/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "ethereum-validator-watcher.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "ethereum-validator-watcher.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "ethereum-validator-watcher.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "ethereum-validator-watcher.labels" -}} 37 | helm.sh/chart: {{ include "ethereum-validator-watcher.chart" . }} 38 | {{ include "ethereum-validator-watcher.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "ethereum-validator-watcher.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "ethereum-validator-watcher.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "ethereum-validator-watcher.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "ethereum-validator-watcher.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /charts/eth-validator-watcher/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "ethereum-validator-watcher.fullname" . }}-config 5 | labels: 6 | {{- include "ethereum-validator-watcher.labels" . | nindent 4 }} 7 | data: 8 | config.yaml: |- 9 | {{ .Values.config | nindent 4 }} 10 | -------------------------------------------------------------------------------- /charts/eth-validator-watcher/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "ethereum-validator-watcher.fullname" . }} 5 | namespace: {{ .Release.Namespace }} 6 | labels: 7 | {{- include "ethereum-validator-watcher.labels" . | nindent 4 }} 8 | {{- with .Values.deploymentAnnotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | spec: 13 | strategy: 14 | type: Recreate 15 | replicas: {{ .Values.replicaCount }} 16 | selector: 17 | matchLabels: 18 | {{- include "ethereum-validator-watcher.selectorLabels" . | nindent 6 }} 19 | template: 20 | metadata: 21 | labels: 22 | {{- include "ethereum-validator-watcher.labels" . | nindent 8 }} 23 | {{- with .Values.podLabels }} 24 | {{- tpl (toYaml .) $ | nindent 8 }} 25 | {{- end }} 26 | annotations: 27 | checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} 28 | {{- with .Values.podAnnotations }} 29 | {{ tpl (toYaml .) $ | nindent 8 }} 30 | {{- end }} 31 | spec: 32 | containers: 33 | - name: validator-watcher 34 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 35 | imagePullPolicy: {{ .Values.image.pullPolicy }} 36 | {{- with .Values.resources }} 37 | resources: 38 | {{- toYaml . | nindent 10 }} 39 | {{- end }} 40 | ports: 41 | - name: metrics 42 | containerPort: 8000 43 | {{- with .Values.env }} 44 | env: 45 | {{- range $name, $value := . }} 46 | {{- $type := typeOf $value }} 47 | - name: {{ $name }} 48 | {{- if eq $type "string" }} 49 | value: {{ $value | quote }} 50 | {{- else }} 51 | {{- toYaml $value | nindent 12 }} 52 | {{- end }} 53 | {{- end }} 54 | {{- end }} 55 | args: 56 | - --config=/config/config.yaml 57 | {{- range $key, $value := .Values.extraArgs }} 58 | {{- if eq ($value | quote | len) 2 }} 59 | - --{{ $key }} 60 | {{- else }} 61 | - --{{ $key }}={{ $value }} 62 | {{- end }} 63 | {{- end }} 64 | {{- with .Values.livenessProbe }} 65 | livenessProbe: 66 | {{- toYaml . | nindent 10 }} 67 | {{- end }} 68 | {{- with .Values.startupProbe }} 69 | startupProbe: 70 | {{- toYaml . | nindent 10 }} 71 | {{- end }} 72 | {{- with .Values.readinessProbe }} 73 | readinessProbe: 74 | {{- toYaml . | nindent 10 }} 75 | {{- end }} 76 | volumeMounts: 77 | - name: config 78 | mountPath: /config 79 | serviceAccountName: {{ include "ethereum-validator-watcher.serviceAccountName" . }} 80 | {{- with .Values.nodeSelector }} 81 | nodeSelector: 82 | {{- toYaml . | nindent 8 }} 83 | {{- end }} 84 | {{- with .Values.affinity }} 85 | affinity: 86 | {{- toYaml . | nindent 6 }} 87 | {{- end }} 88 | volumes: 89 | - name: config 90 | configMap: 91 | name: {{ include "ethereum-validator-watcher.fullname" . }}-config 92 | -------------------------------------------------------------------------------- /charts/eth-validator-watcher/templates/podmonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.podMonitor.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: PodMonitor 4 | metadata: 5 | name: {{ include "ethereum-validator-watcher.fullname" . }} 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | {{- include "ethereum-validator-watcher.labels" . | nindent 4 }} 9 | spec: 10 | selector: 11 | matchLabels: 12 | {{- include "ethereum-validator-watcher.selectorLabels" . | nindent 8 }} 13 | podMetricsEndpoints: 14 | - port: metrics 15 | {{- if .Values.podMonitor.interval }} 16 | interval: {{ .Values.podMonitor.interval }} 17 | {{- end }} 18 | {{- if .Values.podMonitor.additionalLabels }} 19 | additionalLabels: 20 | {{- toYaml .Values.podMonitor.additionalLabels | nindent 6 }} 21 | {{- end }} 22 | {{- if .Values.podMonitor.scheme }} 23 | scheme: {{ .Values.podMonitor.scheme }} 24 | {{- end }} 25 | {{- if .Values.podMonitor.tlsConfig }} 26 | tlsConfig: 27 | {{- toYaml .Values.podMonitor.tlsConfig | nindent 6 }} 28 | {{- end }} 29 | {{- if .Values.podMonitor.relabelings }} 30 | relabelings: 31 | {{ toYaml .Values.podMonitor.relabelings | nindent 6 }} 32 | {{- end }} 33 | {{- end }} 34 | -------------------------------------------------------------------------------- /charts/eth-validator-watcher/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create }} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | labels: 6 | {{- include "ethereum-validator-watcher.labels" . | nindent 4 }} 7 | {{- with .Values.serviceAccount.labels }} 8 | {{- toYaml . | nindent 4 }} 9 | {{- end }} 10 | {{- with .Values.serviceAccount.annotations }} 11 | annotations: 12 | {{- toYaml . | nindent 4 }} 13 | {{- end }} 14 | name: {{ include "ethereum-validator-watcher.serviceAccountName" . }} 15 | namespace: {{ .Release.Namespace }} 16 | {{- end }} 17 | -------------------------------------------------------------------------------- /charts/eth-validator-watcher/values.yaml: -------------------------------------------------------------------------------- 1 | # Kubernetes config, likely no need to tweak those unless you know 2 | # what you are doing and need specific tweaks. 3 | 4 | replicaCount: 1 5 | 6 | image: 7 | repository: ghcr.io/kilnfi/eth-validator-watcher 8 | pullPolicy: IfNotPresent 9 | tag: 1.0.0-beta.8 10 | 11 | nameOverride: "" 12 | fullnameOverride: "" 13 | 14 | extraArgs: {} 15 | deploymentAnnotations: {} 16 | podAnnotations: {} 17 | podLabels: {} 18 | podSecurityContext: {} 19 | securityContext: {} 20 | 21 | resources: {} 22 | 23 | startupProbe: {} 24 | readinessProbe: {} 25 | livenessProbe: 26 | httpGet: 27 | path: / 28 | port: 8000 29 | initialDelaySeconds: 600 30 | failureThreshold: 10 31 | periodSeconds: 60 32 | timeoutSeconds: 60 33 | 34 | nodeSelector: {} 35 | tolerations: [] 36 | affinity: {} 37 | 38 | serviceAccount: 39 | create: true 40 | annotations: {} 41 | name: "" 42 | labels: {} 43 | 44 | podMonitor: 45 | enabled: true 46 | interval: 12s 47 | additionalLabels: {} 48 | scheme: http 49 | tlsConfig: {} 50 | relabelings: [] 51 | 52 | # Here is the actual configuration of the watcher. This needs to be 53 | # edited to your environment / setup / keys. 54 | 55 | config: | 56 | beacon_url: http://beacon-url:5051 57 | beacon_timeout_sec: 90 58 | network: network-name 59 | metrics_port: 8000 60 | watched_keys: 61 | - public_key: '989fa046d04b41fc95a04dabb7ab8b64e84afaa85c0aa49e1c6878d7b2814094402d62ae42dfbf3ac72e6770ee0926a8' 62 | labels: ['operator:kiln', 'vc:prysm-validator-1'] 63 | -------------------------------------------------------------------------------- /docs/img/Kiln_Logo-Transparent-Dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/img/watcher-breakdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilnfi/eth-validator-watcher/da97e884b4e349e640f74ed779f8ad41c2fcc845/docs/img/watcher-breakdown.png -------------------------------------------------------------------------------- /docs/img/watcher-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilnfi/eth-validator-watcher/da97e884b4e349e640f74ed779f8ad41c2fcc845/docs/img/watcher-overview.png -------------------------------------------------------------------------------- /etc/.gitignore: -------------------------------------------------------------------------------- 1 | config.dev.yaml 2 | config.py 3 | mainnet.yaml -------------------------------------------------------------------------------- /etc/config.local.yaml: -------------------------------------------------------------------------------- 1 | # Example config file for the Ethereum watcher. 2 | 3 | beacon_url: http://localhost:5051/ 4 | beacon_type: other 5 | execution_url: ~ 6 | web3signer_url: ~ 7 | default_fee_recipient: ~ 8 | slack_channel: ~ 9 | slack_token: ~ 10 | relays: ~ 11 | liveness_file: ~ 12 | 13 | # This mapping is reloaded dynamically at the beginning of each 14 | # epoch. If the new mapping is invalid the watcher will crash, be sure 15 | # to use atomic filesystem operations to have a completely updated 16 | # configuration file if you dynamically watch keys. 17 | watched_keys: 18 | - public_key: '0x832b8286f5d6535fd941c6c4ed8b9b20d214fc6aa726ce4fba1c9dbb4f278132646304f550e557231b6932aa02cf08d3' 19 | labels: ['google'] 20 | fee_recipient: ~ 21 | -------------------------------------------------------------------------------- /eth_validator_watcher/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilnfi/eth-validator-watcher/da97e884b4e349e640f74ed779f8ad41c2fcc845/eth_validator_watcher/__init__.py -------------------------------------------------------------------------------- /eth_validator_watcher/beacon.py: -------------------------------------------------------------------------------- 1 | """Contains the Beacon class which is used to interact with the consensus layer node.""" 2 | 3 | import functools 4 | from typing import Any, Union 5 | 6 | from requests import HTTPError, Response, Session, codes 7 | from requests.adapters import HTTPAdapter, Retry 8 | from requests.exceptions import ChunkedEncodingError 9 | from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed 10 | 11 | from .models import ( 12 | Attestations, 13 | BlockIdentierType, 14 | Committees, 15 | Genesis, 16 | Header, 17 | PendingConsolidations, 18 | PendingDeposits, 19 | PendingWithdrawals, 20 | ProposerDuties, 21 | Rewards, 22 | Spec, 23 | Validators, 24 | ValidatorsLivenessResponse, 25 | ) 26 | 27 | 28 | print = functools.partial(print, flush=True) 29 | 30 | 31 | class NoBlockError(Exception): 32 | pass 33 | 34 | 35 | class Beacon: 36 | """Beacon node abstraction.""" 37 | 38 | def __init__(self, url: str, timeout_sec: int) -> None: 39 | """Initialize a Beacon instance. 40 | 41 | Args: 42 | url: str 43 | URL where the beacon can be reached. 44 | timeout_sec: int 45 | Timeout in seconds used to query the beacon. 46 | 47 | Returns: 48 | None 49 | """ 50 | self._url = url 51 | self._timeout_sec = timeout_sec 52 | self._http_retry_not_found = Session() 53 | self._http = Session() 54 | self._first_liveness_call = True 55 | self._first_rewards_call = True 56 | 57 | adapter_retry_not_found = HTTPAdapter( 58 | max_retries=Retry( 59 | backoff_factor=1, 60 | total=5, 61 | status_forcelist=[ 62 | codes.not_found, 63 | codes.bad_gateway, 64 | codes.service_unavailable, 65 | ], 66 | ) 67 | ) 68 | 69 | adapter = HTTPAdapter( 70 | max_retries=Retry( 71 | backoff_factor=0.5, 72 | total=3, 73 | status_forcelist=[ 74 | codes.bad_gateway, 75 | codes.service_unavailable, 76 | ], 77 | ) 78 | ) 79 | 80 | self._http_retry_not_found.mount("http://", adapter_retry_not_found) 81 | self._http_retry_not_found.mount("https://", adapter_retry_not_found) 82 | 83 | self._http.mount("http://", adapter) 84 | self._http.mount("https://", adapter) 85 | 86 | @retry( 87 | stop=stop_after_attempt(5), 88 | wait=wait_fixed(3), 89 | retry=retry_if_exception_type(ChunkedEncodingError), 90 | ) 91 | def _get_retry_not_found(self, *args: Any, **kwargs: Any) -> Response: 92 | """Wrapper around requests.get() with retry on 404. 93 | 94 | Args: 95 | *args: Any 96 | Positional arguments to pass to requests.get(). 97 | **kwargs: Any 98 | Keyword arguments to pass to requests.get(). 99 | 100 | Returns: 101 | Response 102 | The HTTP response. 103 | """ 104 | return self._http_retry_not_found.get(*args, **kwargs) 105 | 106 | @retry( 107 | stop=stop_after_attempt(5), 108 | wait=wait_fixed(3), 109 | retry=retry_if_exception_type(ChunkedEncodingError), 110 | ) 111 | def _get(self, *args: Any, **kwargs: Any) -> Response: 112 | """Wrapper around requests.get(). 113 | 114 | Args: 115 | *args: Any 116 | Positional arguments to pass to requests.get(). 117 | **kwargs: Any 118 | Keyword arguments to pass to requests.get(). 119 | 120 | Returns: 121 | Response 122 | The HTTP response. 123 | """ 124 | return self._http.get(*args, **kwargs) 125 | 126 | @retry( 127 | stop=stop_after_attempt(5), 128 | wait=wait_fixed(3), 129 | retry=retry_if_exception_type(ChunkedEncodingError), 130 | ) 131 | def _post_retry_not_found(self, *args: Any, **kwargs: Any) -> Response: 132 | """Wrapper around requests.post() with retry on 404. 133 | 134 | Args: 135 | *args: Any 136 | Positional arguments to pass to requests.post(). 137 | **kwargs: Any 138 | Keyword arguments to pass to requests.post(). 139 | 140 | Returns: 141 | Response 142 | The HTTP response. 143 | """ 144 | return self._http_retry_not_found.post(*args, **kwargs) 145 | 146 | def get_url(self) -> str: 147 | """Get the URL of the beacon node. 148 | 149 | Args: 150 | None 151 | 152 | Returns: 153 | str 154 | The URL of the beacon node. 155 | """ 156 | return self._url 157 | 158 | def get_timeout_sec(self) -> int: 159 | """Get the timeout in seconds used to query the beacon. 160 | 161 | Args: 162 | None 163 | 164 | Returns: 165 | int 166 | The timeout in seconds. 167 | """ 168 | return self._timeout_sec 169 | 170 | def get_genesis(self) -> Genesis: 171 | """Get beacon chain genesis data. 172 | 173 | Args: 174 | None 175 | 176 | Returns: 177 | Genesis 178 | The beacon chain genesis data. 179 | """ 180 | response = self._get_retry_not_found( 181 | f"{self._url}/eth/v1/beacon/genesis", timeout=self._timeout_sec 182 | ) 183 | 184 | response.raise_for_status() 185 | 186 | return Genesis.model_validate_json(response.text) 187 | 188 | def get_spec(self) -> Spec: 189 | """Get beacon chain specification data. 190 | 191 | Args: 192 | None 193 | 194 | Returns: 195 | Spec 196 | The beacon chain specification data. 197 | """ 198 | response = self._get_retry_not_found( 199 | f"{self._url}/eth/v1/config/spec", timeout=self._timeout_sec 200 | ) 201 | 202 | response.raise_for_status() 203 | 204 | return Spec.model_validate_json(response.text) 205 | 206 | def get_committees(self, slot: int) -> Committees: 207 | """Get beacon chain committees for a specific slot. 208 | 209 | Args: 210 | slot: int 211 | Slot corresponding to the committees to retrieve. 212 | 213 | Returns: 214 | Committees 215 | The committee assignments for the specified slot. 216 | """ 217 | response = self._get( 218 | f"{self._url}/eth/v1/beacon/states/{slot}/committees?slot={slot}", timeout=self._timeout_sec 219 | ) 220 | response.raise_for_status() 221 | 222 | return Committees.model_validate_json(response.text) 223 | 224 | def get_attestations(self, slot: int) -> Attestations: 225 | """Get attestations from a specific block. 226 | 227 | Args: 228 | slot: int 229 | Slot corresponding to the block in which attestations are present. 230 | 231 | Returns: 232 | Attestations 233 | The attestations from the specified block, or None if the block doesn't exist. 234 | """ 235 | try: 236 | response = self._get( 237 | f"{self._url}/eth/v2/beacon/blocks/{slot}/attestations", timeout=self._timeout_sec 238 | ) 239 | response.raise_for_status() 240 | except HTTPError as e: 241 | if e.response.status_code == codes.not_found: 242 | return None 243 | # If we are here, it's an other error 244 | raise 245 | 246 | return Attestations.model_validate_json(response.text) 247 | 248 | def get_header(self, block_identifier: Union[BlockIdentierType, int]) -> Header: 249 | """Get a block header. 250 | 251 | Args: 252 | block_identifier: Union[BlockIdentierType, int] 253 | Block identifier or slot corresponding to the block to retrieve. 254 | 255 | Returns: 256 | Header 257 | The block header for the specified block. 258 | 259 | Raises: 260 | NoBlockError: If the block does not exist. 261 | HTTPError: For other HTTP errors. 262 | """ 263 | try: 264 | response = self._get( 265 | f"{self._url}/eth/v1/beacon/headers/{block_identifier}", timeout=self._timeout_sec 266 | ) 267 | response.raise_for_status() 268 | except HTTPError as e: 269 | if e.response.status_code == codes.not_found: 270 | # If we are here, it means the block does not exist 271 | raise NoBlockError from e 272 | # If we are here, it's an other error 273 | raise 274 | 275 | return Header.model_validate_json(response.text) 276 | 277 | def get_proposer_duties(self, epoch: int) -> ProposerDuties: 278 | """Get proposer duties for a specific epoch. 279 | 280 | Args: 281 | epoch: int 282 | Epoch corresponding to the proposer duties to retrieve. 283 | 284 | Returns: 285 | ProposerDuties 286 | The proposer duties for the specified epoch. 287 | """ 288 | response = self._get_retry_not_found( 289 | f"{self._url}/eth/v1/validator/duties/proposer/{epoch}", timeout=self._timeout_sec 290 | ) 291 | 292 | response.raise_for_status() 293 | 294 | return ProposerDuties.model_validate_json(response.text) 295 | 296 | def get_validators(self, slot: int) -> Validators: 297 | """Get validator information for a specific slot. 298 | 299 | Args: 300 | slot: int 301 | Slot for which to retrieve validator information. 302 | 303 | Returns: 304 | Validators 305 | The validator information for the specified slot. 306 | """ 307 | response = self._get_retry_not_found( 308 | f"{self._url}/eth/v1/beacon/states/{slot}/validators", timeout=self._timeout_sec 309 | ) 310 | 311 | response.raise_for_status() 312 | 313 | return Validators.model_validate_json(response.text) 314 | 315 | def get_rewards(self, epoch: int) -> Rewards: 316 | """Get attestation rewards for a specific epoch. 317 | 318 | Args: 319 | epoch: int 320 | Epoch corresponding to the rewards to retrieve. 321 | 322 | Returns: 323 | Rewards 324 | The attestation rewards for the specified epoch. 325 | """ 326 | response = self._post_retry_not_found( 327 | f"{self._url}/eth/v1/beacon/rewards/attestations/{epoch}", 328 | json=([]), 329 | timeout=self._timeout_sec, 330 | ) 331 | 332 | response.raise_for_status() 333 | 334 | return Rewards.model_validate_json(response.text) 335 | 336 | def get_validators_liveness(self, epoch: int, indexes: list[int]) -> ValidatorsLivenessResponse: 337 | """Get validators liveness information for a specific epoch. 338 | 339 | Args: 340 | epoch: int 341 | Epoch corresponding to the validators liveness to retrieve. 342 | indexes: list[int] 343 | List of validator indexes to check liveness for. 344 | 345 | Returns: 346 | ValidatorsLivenessResponse 347 | The liveness information for the specified validators. 348 | """ 349 | response = self._post_retry_not_found( 350 | f"{self._url}/eth/v1/validator/liveness/{epoch}", 351 | json=[f"{i}" for i in indexes], 352 | timeout=self._timeout_sec, 353 | ) 354 | 355 | response.raise_for_status() 356 | 357 | return ValidatorsLivenessResponse.model_validate_json(response.text) 358 | 359 | def get_pending_deposits(self) -> PendingDeposits: 360 | """Get beacon chain pending deposits. 361 | 362 | Args: 363 | None 364 | 365 | Returns: 366 | PendingDeposits 367 | The beacon chain pending deposits. 368 | """ 369 | response = self._get_retry_not_found( 370 | f"{self._url}/eth/v1/beacon/states/head/pending_deposits", timeout=self._timeout_sec 371 | ) 372 | 373 | response.raise_for_status() 374 | 375 | return PendingDeposits.model_validate_json(response.text) 376 | 377 | def get_pending_consolidations(self) -> PendingConsolidations: 378 | """Get beacon chain pending consolidations. 379 | 380 | Args: 381 | None 382 | 383 | Returns: 384 | PendingConsolidations 385 | The beacon chain pending consolidations. 386 | """ 387 | response = self._get_retry_not_found( 388 | f"{self._url}/eth/v1/beacon/states/head/pending_consolidations", timeout=self._timeout_sec 389 | ) 390 | 391 | response.raise_for_status() 392 | 393 | return PendingConsolidations.model_validate_json(response.text) 394 | 395 | def get_pending_withdrawals(self) -> PendingWithdrawals: 396 | """Get beacon chain pending withdrawals. 397 | 398 | Args: 399 | None 400 | 401 | Returns: 402 | PendingWithdrawals 403 | The beacon chain pending withdrawals. 404 | """ 405 | response = self._get_retry_not_found( 406 | f"{self._url}/eth/v1/beacon/states/head/pending_partial_withdrawals", timeout=self._timeout_sec 407 | ) 408 | 409 | response.raise_for_status() 410 | 411 | return PendingWithdrawals.model_validate_json(response.text) 412 | 413 | def has_block_at_slot(self, block_identifier: BlockIdentierType | int) -> bool: 414 | """Returns the slot of a block identifier if it exists. 415 | 416 | Args: 417 | ----- 418 | block_identifier: BlockIdentierType | int 419 | Block identifier (i.e: head, finalized, 42, etc). 420 | 421 | Returns: 422 | -------- 423 | bool: True if the block exists, False otherwise. 424 | """ 425 | try: 426 | return self.get_header(block_identifier).data.header.message.slot > 0 427 | except NoBlockError: 428 | return False 429 | -------------------------------------------------------------------------------- /eth_validator_watcher/blocks.py: -------------------------------------------------------------------------------- 1 | from .proposer_schedule import ProposerSchedule 2 | from .watched_validators import WatchedValidators 3 | 4 | 5 | def process_block(validators: WatchedValidators, schedule: ProposerSchedule, slot_id: int, has_block: bool) -> None: 6 | """Process a block from the head (non-finalized) chain. 7 | 8 | Args: 9 | validators: WatchedValidators 10 | The registry of validators being watched. 11 | schedule: ProposerSchedule 12 | The proposer schedule to look up who was supposed to propose. 13 | slot_id: int 14 | The slot ID being processed. 15 | has_block: bool 16 | Whether a block was found in this slot. 17 | 18 | Returns: 19 | None 20 | """ 21 | validator_index = schedule.get_proposer(slot_id) 22 | if validator_index is None: 23 | return 24 | 25 | validator = validators.get_validator_by_index(validator_index) 26 | if validator is None: 27 | return 28 | 29 | validator.process_block(slot_id, has_block) 30 | 31 | 32 | def process_finalized_block(validators: WatchedValidators, schedule: ProposerSchedule, slot_id: int, has_block: bool) -> None: 33 | """Process a block from the finalized chain. 34 | 35 | Args: 36 | validators: WatchedValidators 37 | The registry of validators being watched. 38 | schedule: ProposerSchedule 39 | The proposer schedule to look up who was supposed to propose. 40 | slot_id: int 41 | The slot ID being processed. 42 | has_block: bool 43 | Whether a block was found in this slot. 44 | 45 | Returns: 46 | None 47 | """ 48 | validator_index = schedule.get_proposer(slot_id) 49 | if validator_index is None: 50 | return 51 | 52 | validator = validators.get_validator_by_index(validator_index) 53 | if validator is None: 54 | return 55 | 56 | validator.process_block_finalized(slot_id, has_block) 57 | 58 | 59 | def process_future_blocks(validators: WatchedValidators, schedule: ProposerSchedule, slot_id: int) -> None: 60 | """Process future block proposals based on the proposer schedule. 61 | 62 | Args: 63 | validators: WatchedValidators 64 | The registry of validators being watched. 65 | schedule: ProposerSchedule 66 | The proposer schedule containing future proposals. 67 | slot_id: int 68 | The current slot ID (future proposals will be after this). 69 | 70 | Returns: 71 | None 72 | """ 73 | future_proposals = schedule.get_future_proposals(slot_id) 74 | 75 | for slot_id, validator_index in future_proposals.items(): 76 | validator = validators.get_validator_by_index(validator_index) 77 | if validator is None: 78 | continue 79 | 80 | validator.process_future_block(slot_id) 81 | -------------------------------------------------------------------------------- /eth_validator_watcher/clock.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | 4 | 5 | class BeaconClock: 6 | """Helper class to keep track of the beacon clock. 7 | 8 | This clock is slightly skewed to ensure we have the data for the 9 | slot we are processing: it is possible beacons do not have data 10 | exactly on slot time, so we wait for ~4 seconds into the next 11 | slot. 12 | """ 13 | 14 | def __init__(self, genesis: int, slot_duration: int, slots_per_epoch: int, replay_start_at: int | None, replay_end_at: int | None) -> None: 15 | """Initialize the BeaconClock. 16 | 17 | Args: 18 | genesis: int 19 | Genesis timestamp of the beacon chain. 20 | slot_duration: int 21 | Duration of a slot in seconds. 22 | slots_per_epoch: int 23 | Number of slots in an epoch. 24 | replay_start_at: int | None 25 | Start timestamp for replay mode, or None for live mode. 26 | replay_end_at: int | None 27 | End timestamp for replay mode, or None for indefinite replay. 28 | 29 | Returns: 30 | None 31 | """ 32 | self._genesis = genesis 33 | self._slot_duration = slot_duration 34 | self._slots_per_epoch = slots_per_epoch 35 | 36 | # Current slot is being built, waiting 8 seconds on the last 37 | # slot with some extra time to ensure we have the data for the 38 | # slot (attestations). 39 | self._lag_seconds = self._slot_duration + 8 40 | self._init_at = time.time() 41 | 42 | # Replay mode 43 | self._replay_start_at = replay_start_at 44 | self._replay_end_at = replay_end_at 45 | self._replay_elapsed_ = 0.0 46 | 47 | if self._replay_start_at is not None: 48 | logging.info(f'⏰ Starting clock at timestamp @ {self._replay_start_at}') 49 | 50 | def now(self) -> float: 51 | """Get the current time in seconds since the epoch. 52 | 53 | Returns: 54 | -------- 55 | float: Current time in seconds since the epoch. 56 | """ 57 | if self._replay_start_at is not None: 58 | return self._replay_start_at + self._replay_elapsed_ 59 | 60 | return time.time() - self._lag_seconds 61 | 62 | def get_current_epoch(self) -> int: 63 | """Get the current epoch. 64 | 65 | Returns: 66 | -------- 67 | int: Current epoch. 68 | """ 69 | return self.get_current_slot() // self._slots_per_epoch 70 | 71 | def epoch_to_slot(self, epoch: int) -> int: 72 | """Convert an epoch to a slot. 73 | 74 | Args: 75 | ----- 76 | epoch: int 77 | Epoch to convert. 78 | 79 | Returns: 80 | -------- 81 | int: Slot corresponding to the epoch. 82 | """ 83 | return epoch * self._slots_per_epoch 84 | 85 | def get_current_slot(self) -> int: 86 | """Get the current slot. 87 | 88 | Returns: 89 | -------- 90 | int: Current slot. 91 | """ 92 | return int((self.now() - self._genesis) // self._slot_duration) 93 | 94 | def maybe_wait_for_slot(self, slot: int) -> None: 95 | """Wait until the given slot is reached. 96 | 97 | In replay mode, this will fast-forward the clock to the given slot. 98 | 99 | Args: 100 | ----- 101 | slot: int 102 | Slot to wait for. 103 | """ 104 | if self._replay_start_at is not None: 105 | logging.info(f'⏰ Fast-forwarding to slot {slot}') 106 | self._replay_elapsed_ += (slot - self.get_current_slot()) * self._slot_duration + self._lag_seconds 107 | return 108 | 109 | target = self._genesis + slot * self._slot_duration + self._lag_seconds 110 | now = self.now() 111 | if now < target: 112 | logging.info(f'⏰ Waiting {target - now:.2f} seconds for slot {slot}') 113 | time.sleep(target - now) 114 | -------------------------------------------------------------------------------- /eth_validator_watcher/coinbase.py: -------------------------------------------------------------------------------- 1 | """Helper to fetch the ETH/USD price from Coinbase.""" 2 | 3 | from cachetools import func 4 | from pydantic import parse_obj_as 5 | from requests import Session 6 | 7 | from .models import CoinbaseTrade 8 | 9 | 10 | URL = "https://api.exchange.coinbase.com/products/ETH-USD/trades" 11 | 12 | 13 | @func.ttl_cache(ttl=600) 14 | def get_current_eth_price() -> float: 15 | """Get the current ETH price in USD from Coinbase. 16 | 17 | Args: 18 | None 19 | 20 | Returns: 21 | float 22 | The current ETH price in USD, or 0.0 if fetching fails. 23 | """ 24 | try: 25 | response = Session().get(URL, params=dict(limit=1)) 26 | trades_dict = response.json() 27 | trades = parse_obj_as(list[CoinbaseTrade], trades_dict) 28 | trade, *_ = trades 29 | except Exception: 30 | # This feature is totally optional, so if it fails, we just 31 | # return 0.0. 32 | return 0.0 33 | 34 | return trade.price 35 | -------------------------------------------------------------------------------- /eth_validator_watcher/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from pydantic_settings import BaseSettings, SettingsConfigDict 3 | from typing import List, Optional 4 | 5 | import logging 6 | import json 7 | import yaml 8 | 9 | 10 | class WatchedKeyConfig(BaseModel): 11 | """Configuration model for a watched validator key. 12 | 13 | Args: 14 | None 15 | 16 | Returns: 17 | None 18 | """ 19 | public_key: str 20 | labels: Optional[list[str]] = None 21 | 22 | 23 | class Config(BaseSettings): 24 | """Configuration model for the Ethereum Validator Watcher. 25 | 26 | Args: 27 | None 28 | 29 | Returns: 30 | None 31 | """ 32 | model_config = SettingsConfigDict(case_sensitive=True, env_prefix='eth_watcher_') 33 | 34 | network: Optional[str] = None 35 | beacon_url: Optional[str] = None 36 | beacon_timeout_sec: Optional[int] = None 37 | metrics_port: Optional[int] = None 38 | watched_keys: Optional[List[WatchedKeyConfig]] = None 39 | 40 | slack_token: Optional[str] = None 41 | slack_channel: Optional[str] = None 42 | 43 | replay_start_at_ts: Optional[int] = None 44 | replay_end_at_ts: Optional[int] = None 45 | 46 | 47 | def _default_config() -> Config: 48 | """Create and return the default configuration. 49 | 50 | Args: 51 | None 52 | 53 | Returns: 54 | Config 55 | The default configuration instance. 56 | """ 57 | return Config( 58 | network='mainnet', 59 | beacon_url='http://localhost:5051/', 60 | beacon_timeout_sec=90, 61 | metrics_port=8000, 62 | watched_keys=[], 63 | ) 64 | 65 | 66 | def load_config(config_file: str) -> Config: 67 | """Load and merge configuration from environment variables and config file. 68 | 69 | Environment variables have priority and can be used to set secrets 70 | and override the config file values. 71 | 72 | Args: 73 | config_file: str 74 | Path to the YAML or JSON configuration file. 75 | 76 | Returns: 77 | Config 78 | The effective configuration used by the watcher. 79 | """ 80 | with open(config_file, 'r') as fh: 81 | logging.info(f'⚙️ Parsing configuration file {config_file}') 82 | 83 | # We support json for large configuration files (500 MiB) 84 | # which can take time to parse with PyYAML. 85 | if config_file.endswith('.json'): 86 | config = json.load(fh) 87 | else: 88 | config = yaml.load(fh, Loader=yaml.CLoader) or dict() 89 | 90 | logging.info('⚙️ Validating configuration file') 91 | from_default = _default_config().model_dump() 92 | from_env = Config().model_dump() 93 | from_file = Config(**config).model_dump() 94 | 95 | logging.info('⚙️ Merging with environment variables') 96 | merged = from_default.copy() 97 | 98 | merged.update({k: v for k, v in from_file.items() if v}) 99 | merged.update({k: v for k, v in from_env.items() if v}) 100 | 101 | r = Config(**merged) 102 | 103 | logging.info('⚙️ Configuration file is ready') 104 | 105 | return r 106 | -------------------------------------------------------------------------------- /eth_validator_watcher/duties.py: -------------------------------------------------------------------------------- 1 | from .models import ( 2 | Attestations, 3 | Committees, 4 | ) 5 | from .watched_validators import WatchedValidators 6 | 7 | 8 | def bitfield_to_bitstring(ssz: str, strip_length: bool) -> str: 9 | """Helper to decode an SSZ Bitvector[64]. 10 | 11 | This is a bit tricky since we need to have MSB representation 12 | while Python is LSB oriented. We extract each successive byte from 13 | the hex representation (2 hex digits per byte), convert to binary 14 | representation (LSB), pad it, then reverse it to be MSB. 15 | """ 16 | ssz = ssz.replace('0x', '') 17 | 18 | assert len(ssz) % 2 == 0 19 | 20 | bitstr = '' 21 | 22 | for i in range(int(len(ssz) / 2)): 23 | bin_repr_lsb = bin(int(ssz[i * 2:(i + 1) * 2], 16)).replace('0b', '') 24 | bin_repr_lsb_padded = bin_repr_lsb.rjust(8, '0') 25 | bin_repr_msb = ''.join(reversed(bin_repr_lsb_padded)) 26 | bitstr += bin_repr_msb 27 | 28 | # Bitlists's last bit set to 1 marks the end of the field, we need 29 | # to strip it to have the final set. 30 | if strip_length: 31 | bitstr = bitstr[0:bitstr.rfind('1')] 32 | 33 | return bitstr 34 | 35 | 36 | def process_duties(watched_validators: WatchedValidators, previous_slot_committees: Committees, current_attestations: Attestations, current_slot: int): 37 | """Process validator attestation duties for the current slot. 38 | 39 | The current slot contains attestations from the previous slot (and 40 | potentially older ones). A validator is considered to have 41 | performed its duties if in the current slot it attested for the 42 | previous slot. 43 | 44 | The format is a bit tricky to get right here: attestations are 45 | aggregated per committees and each committee handles a subset of 46 | validators, there are 64 committees. 47 | 48 | Each attestation is composed of two bitfields: 49 | 50 | - committee_bits: 64 entries are present, if set to 1 there are 51 | attestations for validators inside this committee, 52 | - aggregation_bits: length of this bitfield is the SUM(len) of 53 | validators in active committees. 54 | 55 | This works well because there is usually way less "attestation 56 | flavors" on a specific flow, most of the network will vote for the 57 | same thing under normal condition, so there is usually one entry 58 | with most committee bits sets and aggregation bits. There may be 59 | 4-5 different flavors, but most of the time this is less than the 60 | 64 committees so this format is efficient. 61 | 62 | This is defined in the consensus specs as follows: 63 | 64 | ``` 65 | def get_attesting_indices(state: BeaconState, attestation: Attestation) -> Set[ValidatorIndex]: 66 | output: Set[ValidatorIndex] = set() 67 | committee_indices = get_committee_indices(attestation.committee_bits) 68 | committee_offset = 0 69 | for index in committee_indices: 70 | committee = get_beacon_committee(state, attestation.data.slot, index) 71 | committee_attesters = set( 72 | index for i, index in enumerate(committee) if attestation.aggregation_bits[committee_offset + i]) 73 | output = output.union(committee_attesters) 74 | committee_offset += len(committee) 75 | return output 76 | ``` 77 | 78 | Args: 79 | watched_validators: WatchedValidators 80 | Registry of validators being watched. 81 | previous_slot_committees: Committees 82 | Committee assignments for the previous slot. 83 | current_attestations: Attestations 84 | Attestations included in the current slot's block. 85 | current_slot: int 86 | The current slot being processed. 87 | 88 | Returns: 89 | None 90 | """ 91 | validator_duty_performed: dict[int, bool] = {} 92 | 93 | # Prepare the lookup for the committees 94 | committees_lookup: dict[int, list[int]] = {} 95 | for committee in previous_slot_committees.data: 96 | committees_lookup[committee.index] = committee.validators 97 | for v in committee.validators: 98 | validator_duty_performed[v] = False 99 | 100 | for attestation in current_attestations.data: 101 | # Here we are interested in attestations against the previous 102 | # slot, we dismiss whatever is for a prior slot. The goal of 103 | # this metric is to have a real-time view of optimal 104 | # performances. 105 | slot = attestation.data.slot 106 | if slot != current_slot - 1: 107 | continue 108 | 109 | committee_indices = bitfield_to_bitstring(attestation.committee_bits, False) 110 | aggregation_bits = bitfield_to_bitstring(attestation.aggregation_bits, True) 111 | 112 | committee_offset = 0 113 | for index, exists in enumerate(committee_indices): 114 | if exists == "1": 115 | validators_in_committee = committees_lookup[index] 116 | for i in range(len(validators_in_committee)): 117 | if aggregation_bits[committee_offset + i] == "1": 118 | validator_index = validators_in_committee[i] 119 | validator_duty_performed[validator_index] = True 120 | committee_offset += len(validators_in_committee) 121 | 122 | # Update validators 123 | for validator, ok in validator_duty_performed.items(): 124 | v = watched_validators.get_validator_by_index(validator) 125 | # Here we keep both the current slot and the corresponding value, 126 | # this is to avoid iterating over the entire validator set: in 127 | # the compute metrics code we check the slot_id with the 128 | # currently being processed, if it matches we consider the 129 | # value up-to-date. If it doesn't, it means it corresponds to 130 | # its attestation from the previous epoch and the validator 131 | # didn't perform on this slot. 132 | v.process_duties(current_slot, ok) 133 | -------------------------------------------------------------------------------- /eth_validator_watcher/entrypoint.py: -------------------------------------------------------------------------------- 1 | """Main entrypoint module for the Ethereum Validator Watcher.""" 2 | 3 | from pathlib import Path 4 | from prometheus_client import start_http_server 5 | from pydantic import ValidationError 6 | from typing import Optional 7 | 8 | import logging 9 | import typer 10 | 11 | from .beacon import Beacon 12 | from .blocks import process_block, process_finalized_block, process_future_blocks 13 | from .coinbase import get_current_eth_price 14 | from .clock import BeaconClock 15 | from .config import load_config 16 | from .duties import process_duties 17 | from .log import log_details, slack_send 18 | from .metrics import get_prometheus_metrics, compute_validator_metrics 19 | from .models import BlockIdentierType, Validators 20 | from .proposer_schedule import ProposerSchedule 21 | from .rewards import process_rewards 22 | from .queues import ( 23 | get_pending_deposits, 24 | get_pending_consolidations, 25 | get_pending_withdrawals, 26 | ) 27 | from .utils import ( 28 | SLOT_FOR_CONFIG_RELOAD, 29 | SLOT_FOR_MISSED_ATTESTATIONS_PROCESS, 30 | SLOT_FOR_REWARDS_PROCESS, 31 | pct, 32 | ) 33 | from .watched_validators import WatchedValidators 34 | 35 | 36 | app = typer.Typer(add_completion=False) 37 | 38 | # This needs to be global for unit tests as there doesn't seem to be a 39 | # way to stop the prometheus HTTP server in a clean way. We have to 40 | # re-use it from test to test and so need to know whether or not it 41 | # was already started. 42 | prometheus_metrics_thread_started = False 43 | 44 | 45 | class ValidatorWatcher: 46 | """Main class for the Ethereum Validator Watcher. 47 | 48 | Args: 49 | None 50 | 51 | Returns: 52 | None 53 | """ 54 | 55 | def __init__(self, cfg_path: Path) -> None: 56 | """Initialize the Ethereum Validator Watcher. 57 | 58 | Args: 59 | cfg_path: Path 60 | Path to the configuration file. 61 | 62 | Returns: 63 | None 64 | """ 65 | self._metrics = get_prometheus_metrics() 66 | self._metrics_started = False 67 | self._cfg_path = cfg_path 68 | self._cfg = None 69 | self._cfg_last_modified = None 70 | self._beacon = None 71 | self._slot_duration = None 72 | self._genesis = None 73 | 74 | self._reload_config() 75 | 76 | self._spec = self._beacon.get_spec() 77 | genesis = self._beacon.get_genesis().data.genesis_time 78 | 79 | self._clock = BeaconClock( 80 | genesis, 81 | self._spec.data.SECONDS_PER_SLOT, 82 | self._spec.data.SLOTS_PER_EPOCH, 83 | self._cfg.replay_start_at_ts, 84 | self._cfg.replay_end_at_ts, 85 | ) 86 | 87 | self._schedule = ProposerSchedule(self._spec) 88 | self._slot_hook = None 89 | 90 | def _reload_config(self) -> None: 91 | """Reload the configuration file and update beacon client if needed. 92 | 93 | Args: 94 | None 95 | 96 | Returns: 97 | None 98 | """ 99 | try: 100 | if not self._cfg or self._cfg_path.stat().st_mtime != self._cfg_last_modified: 101 | self._cfg = load_config(str(self._cfg_path)) 102 | self._cfg_last_modified = self._cfg_path.stat().st_mtime 103 | except ValidationError as err: 104 | raise typer.BadParameter(f'Invalid configuration file: {err}') 105 | 106 | if self._beacon is None or self._beacon.get_url() != self._cfg.beacon_url or self._beacon.get_timeout_sec() != self._cfg.beacon_timeout_sec: 107 | self._beacon = Beacon(self._cfg.beacon_url, self._cfg.beacon_timeout_sec) 108 | 109 | def _update_metrics( 110 | self, 111 | watched_validators: WatchedValidators, 112 | epoch: int, 113 | slot: int, 114 | pending_deposits: tuple[int, int], 115 | pending_consolidations: int, 116 | pending_withdrawals: int, 117 | ) -> None: 118 | """Update the Prometheus metrics with the watched validators data. 119 | 120 | Args: 121 | watched_validators: WatchedValidators 122 | Registry of validators being watched. 123 | epoch: int 124 | Current epoch. 125 | slot: int 126 | Current slot. 127 | pending_deposits: tuple[int, int] 128 | Number of pending deposits and their total value. 129 | pending_consolidations: int 130 | Number of pending consolidations. 131 | pending_withdrawals: int 132 | Number of pending withdrawals. 133 | 134 | Returns: 135 | None 136 | """ 137 | network = self._cfg.network 138 | 139 | self._metrics.eth_epoch.labels(network).set(epoch) 140 | self._metrics.eth_slot.labels(network).set(slot) 141 | self._metrics.eth_current_price_dollars.labels(network).set(get_current_eth_price()) 142 | 143 | # Queues 144 | 145 | self._metrics.eth_pending_deposits_count.labels(network).set(pending_deposits[0]) 146 | self._metrics.eth_pending_deposits_value.labels(network).set(pending_deposits[1]) 147 | self._metrics.eth_pending_consolidations_count.labels(network).set(pending_consolidations) 148 | self._metrics.eth_pending_withdrawals_count.labels(network).set(pending_withdrawals) 149 | 150 | # We iterate once on the validator set to optimize CPU as 151 | # there is a log of entries here, this makes code here a bit 152 | # more complex and entangled. 153 | 154 | metrics = compute_validator_metrics(watched_validators.get_validators(), slot) 155 | 156 | log_details(self._cfg, watched_validators, metrics, slot) 157 | 158 | for label, m in metrics.items(): 159 | for status in Validators.DataItem.StatusEnum: 160 | value = m.validator_status_count.get(status, 0) 161 | self._metrics.eth_validator_status_count.labels(label, status, network).set(value) 162 | scaled_value = m.validator_status_scaled_count.get(status, 0.0) 163 | self._metrics.eth_validator_status_scaled_count.labels(label, status, network).set(scaled_value) 164 | 165 | for consensus_type in [0, 1, 2]: 166 | value = m.validator_type_count.get(consensus_type, 0) 167 | self._metrics.eth_validator_type_count.labels(label, consensus_type, network).set(value) 168 | scaled_value = m.validator_type_scaled_count.get(consensus_type, 0.0) 169 | self._metrics.eth_validator_type_scaled_count.labels(label, consensus_type, network).set(scaled_value) 170 | 171 | for label, m in metrics.items(): 172 | self._metrics.eth_suboptimal_sources_rate.labels(label, network).set(pct(m.suboptimal_source_count, m.optimal_source_count)) 173 | self._metrics.eth_suboptimal_targets_rate.labels(label, network).set(pct(m.suboptimal_target_count, m.optimal_target_count)) 174 | self._metrics.eth_suboptimal_heads_rate.labels(label, network).set(pct(m.suboptimal_head_count, m.optimal_head_count)) 175 | 176 | self._metrics.eth_ideal_consensus_rewards_gwei.labels(label, network).set(m.ideal_consensus_reward) 177 | self._metrics.eth_actual_consensus_rewards_gwei.labels(label, network).set(m.actual_consensus_reward) 178 | self._metrics.eth_consensus_rewards_rate.labels(label, network).set(pct(m.actual_consensus_reward, m.ideal_consensus_reward, True)) 179 | 180 | self._metrics.eth_missed_attestations_count.labels(label, network).set(m.missed_attestations_count) 181 | self._metrics.eth_missed_attestations_scaled_count.labels(label, network).set(m.missed_attestations_scaled_count) 182 | self._metrics.eth_missed_consecutive_attestations_count.labels(label, network).set(m.missed_consecutive_attestations_count) 183 | self._metrics.eth_missed_consecutive_attestations_scaled_count.labels(label, network).set(m.missed_consecutive_attestations_scaled_count) 184 | self._metrics.eth_slashed_validators_count.labels(label, network).set(m.validator_slashes) 185 | self._metrics.eth_missed_duties_at_slot_count.labels(label, network).set(m.missed_duties_at_slot_count) 186 | self._metrics.eth_missed_duties_at_slot_scaled_count.labels(label, network).set(m.missed_duties_at_slot_scaled_count) 187 | self._metrics.eth_performed_duties_at_slot_count.labels(label, network).set(m.performed_duties_at_slot_count) 188 | self._metrics.eth_performed_duties_at_slot_scaled_count.labels(label, network).set(m.performed_duties_at_slot_scaled_count) 189 | self._metrics.eth_duties_rate.labels(label, network).set(m.duties_rate) 190 | self._metrics.eth_duties_rate_scaled.labels(label, network).set(m.duties_rate_scaled) 191 | 192 | # Here we inc, it's fine since we previously reset the 193 | # counters on each run; we can't use set because those 194 | # metrics are counters. 195 | 196 | self._metrics.eth_block_proposals_head_total.labels(label, network).inc(m.proposed_blocks) 197 | self._metrics.eth_missed_block_proposals_head_total.labels(label, network).inc(m.missed_blocks) 198 | self._metrics.eth_block_proposals_finalized_total.labels(label, network).inc(m.proposed_blocks_finalized) 199 | self._metrics.eth_missed_block_proposals_finalized_total.labels(label, network).inc(m.missed_blocks_finalized) 200 | 201 | self._metrics.eth_future_block_proposals.labels(label, network).set(m.future_blocks_proposal) 202 | 203 | global prometheus_metrics_thread_started 204 | if not prometheus_metrics_thread_started: 205 | start_http_server(self._cfg.metrics_port) 206 | prometheus_metrics_thread_started = True 207 | 208 | def run(self) -> None: 209 | """Run the Ethereum Validator Watcher main processing loop. 210 | 211 | Args: 212 | None 213 | 214 | Returns: 215 | None 216 | """ 217 | watched_validators = WatchedValidators() 218 | epoch = self._clock.get_current_epoch() 219 | slot = self._clock.get_current_slot() 220 | 221 | beacon_validators = None 222 | validators_liveness = None 223 | rewards = None 224 | last_processed_finalized_slot = None 225 | pending_deposits = None 226 | pending_consolidations = None 227 | pending_withdrawals = None 228 | 229 | slack_send(self._cfg, f'🚀 *Ethereum Validator Watcher* started on {self._cfg.network}, watching {len(self._cfg.watched_keys)} validators') 230 | 231 | while True: 232 | logging.info(f'🔨 Processing slot {slot}') 233 | 234 | last_finalized_slot = self._beacon.get_header(BlockIdentierType.FINALIZED).data.header.message.slot 235 | self._schedule.update(self._beacon, slot) 236 | 237 | if beacon_validators is None or (slot % self._spec.data.SLOTS_PER_EPOCH == 0): 238 | logging.info(f'🔨 Processing epoch {epoch}') 239 | beacon_validators = self._beacon.get_validators(self._clock.epoch_to_slot(epoch)) 240 | watched_validators.process_epoch(beacon_validators) 241 | if not watched_validators.config_initialized: 242 | watched_validators.process_config(self._cfg) 243 | 244 | if pending_deposits is None or (slot % self._spec.data.SLOTS_PER_EPOCH == 0): 245 | logging.info('🔨 Fetching pending deposits') 246 | pending_deposits = get_pending_deposits(self._beacon) 247 | 248 | if pending_consolidations is None or (slot % self._spec.data.SLOTS_PER_EPOCH == 0): 249 | logging.info('🔨 Fetching pending consolidations') 250 | pending_consolidations = get_pending_consolidations(self._beacon) 251 | 252 | if pending_withdrawals is None or (slot % self._spec.data.SLOTS_PER_EPOCH == 0): 253 | logging.info('🔨 Fetching pending withdrawals') 254 | pending_withdrawals = get_pending_withdrawals(self._beacon) 255 | 256 | if validators_liveness is None or (slot % self._spec.data.SLOTS_PER_EPOCH == SLOT_FOR_MISSED_ATTESTATIONS_PROCESS): 257 | logging.info('🔨 Processing validator liveness') 258 | validators_liveness = self._beacon.get_validators_liveness(epoch - 1, watched_validators.get_indexes()) 259 | watched_validators.process_liveness(validators_liveness, epoch) 260 | 261 | has_block = self._beacon.has_block_at_slot(slot) 262 | 263 | if rewards is None or (slot % self._spec.data.SLOTS_PER_EPOCH == SLOT_FOR_REWARDS_PROCESS): 264 | # There is a possibility the slot is missed, in which 265 | # case we'll have to wait for the next one. 266 | if not has_block: 267 | rewards = None 268 | else: 269 | logging.info('🔨 Trying to process rewards') 270 | rewards = self._beacon.get_rewards(epoch - 2) 271 | process_rewards(watched_validators, rewards) 272 | 273 | process_block(watched_validators, self._schedule, slot, has_block) 274 | process_future_blocks(watched_validators, self._schedule, slot) 275 | 276 | while last_processed_finalized_slot and last_processed_finalized_slot < last_finalized_slot: 277 | logging.info(f'🔨 Processing finalized slot from {last_processed_finalized_slot or last_finalized_slot} to {last_finalized_slot}') 278 | has_block = self._beacon.has_block_at_slot(last_processed_finalized_slot) 279 | process_finalized_block(watched_validators, self._schedule, last_processed_finalized_slot, has_block) 280 | last_processed_finalized_slot += 1 281 | last_processed_finalized_slot = last_finalized_slot 282 | 283 | logging.info('🔨 Processing committees for previous slot') 284 | # Here we are looking at attestations in the current slot, 285 | # which were for the previous slot, this is why we get the 286 | # previous committees. 287 | previous_slot_committees = self._beacon.get_committees(slot - 1) 288 | # But we fetch attestations in the current slot (we expect 289 | # to find most of what we want for the previous slot). 290 | # There can be no attestations if the block is entirely 291 | # missed. 292 | current_attestations = self._beacon.get_attestations(slot) 293 | if current_attestations: 294 | process_duties(watched_validators, previous_slot_committees, current_attestations, slot) 295 | 296 | logging.info('🔨 Updating Prometheus metrics') 297 | self._update_metrics(watched_validators, epoch, slot, pending_deposits, pending_consolidations, pending_withdrawals) 298 | 299 | if (slot % self._spec.data.SLOTS_PER_EPOCH == SLOT_FOR_CONFIG_RELOAD): 300 | logging.info('🔨 Processing configuration update') 301 | self._reload_config() 302 | watched_validators.process_config(self._cfg) 303 | 304 | self._schedule.clear(last_processed_finalized_slot) 305 | self._clock.maybe_wait_for_slot(slot + 1) 306 | 307 | if self._slot_hook: 308 | self._slot_hook(slot) 309 | 310 | if self._cfg.replay_end_at_ts and self._clock.now() >= self._cfg.replay_end_at_ts: 311 | logging.info('💨 Replay mode ended, exiting') 312 | break 313 | 314 | slot += 1 315 | epoch = slot // self._spec.data.SLOTS_PER_EPOCH 316 | 317 | 318 | @app.command() 319 | def handler( 320 | config: Optional[Path] = typer.Option( 321 | 'etc/config.local.yaml', 322 | help="File containing the Ethereum Validator Watcher configuration file.", 323 | exists=True, 324 | file_okay=True, 325 | dir_okay=False, 326 | show_default=True, 327 | ), 328 | ) -> None: 329 | """Command line handler to run the Ethereum Validator Watcher. 330 | 331 | Args: 332 | config: Optional[Path] 333 | Path to the configuration file. 334 | 335 | Returns: 336 | None 337 | """ 338 | logging.basicConfig( 339 | level=logging.INFO, 340 | format='%(asctime)s %(levelname)-8s %(message)s' 341 | ) 342 | 343 | watcher = ValidatorWatcher(config) 344 | watcher.run() 345 | -------------------------------------------------------------------------------- /eth_validator_watcher/log.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | 4 | from slack_sdk import WebClient 5 | from slack_sdk.errors import SlackApiError 6 | 7 | from eth_validator_watcher_ext import MetricsByLabel 8 | from .config import Config 9 | from .utils import LABEL_SCOPE_WATCHED, SLOT_FOR_MISSED_ATTESTATIONS_PROCESS 10 | from .watched_validators import WatchedValidators 11 | 12 | # We colorize anything related to validators so that it's easy to spot 13 | # in the log noise from the watcher from actual issues. 14 | COLOR_GREEN = "\x1b[32;20m" 15 | COLOR_BOLD_GREEN = "\x1b[32;1m" 16 | COLOR_YELLOW = "\x1b[33;20m" 17 | COLOR_RED = "\x1b[31;20m" 18 | COLOR_BOLD_RED = "\x1b[31;1m" 19 | COLOR_RESET = "\x1b[0m" 20 | 21 | 22 | def shorten_validator(validator_pubkey: str) -> str: 23 | """Shorten a validator name. 24 | 25 | Args: 26 | validator_pubkey: str 27 | The validator public key to shorten. 28 | 29 | Returns: 30 | str: Shortened validator public key (first 10 characters). 31 | """ 32 | return f"{validator_pubkey[:10]}" 33 | 34 | 35 | def beaconcha_validator_link(cfg: Config, validator: str) -> str: 36 | """Return a link to the beaconcha.in validator page. 37 | 38 | Args: 39 | cfg: Config 40 | Configuration object containing network information. 41 | validator: str 42 | Validator public key. 43 | 44 | Returns: 45 | str: Formatted link to the validator's beaconcha.in page. 46 | """ 47 | return f'' 48 | 49 | 50 | def beaconcha_slot_link(cfg: Config, slot: int) -> str: 51 | """Return a link to the beaconcha.in slot page. 52 | 53 | Args: 54 | cfg: Config 55 | Configuration object containing network information. 56 | slot: int 57 | The slot number to link to. 58 | 59 | Returns: 60 | str: Formatted link to the slot's beaconcha.in page. 61 | """ 62 | return f'' 63 | 64 | 65 | def slack_send(cfg: Config, msg: str) -> None: 66 | """Attempts to send a message to the configured slack channel.""" 67 | if not (cfg.slack_channel and cfg.slack_token): 68 | return 69 | 70 | try: 71 | w = WebClient(token=cfg.slack_token) 72 | w.chat_postMessage(channel=cfg.slack_channel, text=msg) 73 | except SlackApiError as e: 74 | logging.warning(f'😿 Unable to send slack notification: {e.response["error"]}') 75 | 76 | 77 | def log_single_entry(cfg: Config, validator: str, registry: WatchedValidators, msg: str, emoji: str, slot: int, color: str) -> None: 78 | """Logs a single validator entry. 79 | 80 | Args: 81 | cfg: Config 82 | Configuration object containing slack settings. 83 | validator: str 84 | Validator public key. 85 | registry: WatchedValidators 86 | Registry of validators being watched. 87 | msg: str 88 | Message to log. 89 | emoji: str 90 | Emoji to use in the log message. 91 | slot: int 92 | Slot number related to this log entry. 93 | color: str 94 | ANSI color code to use for console output. 95 | 96 | Returns: 97 | None 98 | """ 99 | v = registry.get_validator_by_pubkey(validator) 100 | 101 | label_msg_shell = '' 102 | label_msg_slack = '' 103 | if v: 104 | labels = [label for label in v.labels if not label.startswith('scope:')] 105 | if labels: 106 | label_msg_slack = f' ({", ".join([f"`{label}`" for label in labels])})' 107 | label_msg_shell = f' ({", ".join(labels)})' 108 | 109 | msg_shell = f'{color}{emoji} Validator {shorten_validator(validator)}{label_msg_shell} {msg}{COLOR_RESET}' 110 | logging.info(msg_shell) 111 | 112 | msg_slack = f'{emoji} Validator {beaconcha_validator_link(cfg, validator)}{label_msg_slack} {msg} on slot {beaconcha_slot_link(cfg, slot)}' 113 | slack_send(cfg, msg_slack) 114 | 115 | 116 | def log_multiple_entries(cfg: Config, validators: list[str], registry: WatchedValidators, msg: str, emoji: str, color: str) -> None: 117 | """Logs multiple validator entries. 118 | 119 | Args: 120 | cfg: Config 121 | Configuration object containing slack settings. 122 | validators: list[str] 123 | List of validator public keys. 124 | registry: WatchedValidators 125 | Registry of validators being watched. 126 | msg: str 127 | Message to log. 128 | emoji: str 129 | Emoji to use in the log message. 130 | color: str 131 | ANSI color code to use for console output. 132 | 133 | Returns: 134 | None 135 | """ 136 | 137 | impacted_labels = collections.defaultdict(int) 138 | for validator in validators: 139 | v = registry.get_validator_by_pubkey(validator) 140 | if v: 141 | for label in v.labels: 142 | if not label.startswith('scope'): 143 | impacted_labels[label] += 1 144 | top_labels = sorted(impacted_labels, key=impacted_labels.get, reverse=True)[:5] 145 | 146 | label_msg_slack = '' 147 | label_msg_shell = '' 148 | if top_labels: 149 | label_msg_slack = f' ({", ".join([f"`{label}`" for label in top_labels])})' 150 | label_msg_shell = f' ({", ".join(top_labels)})' 151 | 152 | msg_validators_shell = f'{", ".join([shorten_validator(v) for v in validators])} and more' 153 | msg_shell = f'{color}{emoji} Validator(s) {msg_validators_shell}{label_msg_shell} {msg}{COLOR_RESET}' 154 | logging.info(msg_shell) 155 | 156 | msg_validators_slack = f'{", ".join([beaconcha_validator_link(cfg, v) for v in validators])} and more' 157 | msg_slack = f'{emoji} Validator(s) {msg_validators_slack}{label_msg_slack} {msg}' 158 | slack_send(cfg, msg_slack) 159 | 160 | 161 | def log_details(cfg: Config, registry: WatchedValidators, metrics: MetricsByLabel, current_slot: int) -> None: 162 | """Log details about watched validators. 163 | 164 | Args: 165 | cfg: Config 166 | Configuration object containing network and slack settings. 167 | registry: WatchedValidators 168 | Registry of validators being watched. 169 | metrics: MetricsByLabel 170 | Metrics collected for the validators. 171 | current_slot: int 172 | Current slot being processed. 173 | 174 | Returns: 175 | None 176 | """ 177 | m = metrics.get(LABEL_SCOPE_WATCHED) 178 | if not m: 179 | return None 180 | 181 | for slot, validator in m.details_future_blocks: 182 | # Only log once per epoch future block proposals. 183 | if current_slot % 32 == 0 and slot >= current_slot + 32: 184 | log_single_entry(cfg, validator, registry, 'will propose a block', '🙏', slot, COLOR_GREEN) 185 | 186 | for slot, validator in m.details_proposed_blocks: 187 | log_single_entry(cfg, validator, registry, 'proposed a block', '🏅', slot, COLOR_BOLD_GREEN) 188 | 189 | for slot, validator in m.details_missed_blocks: 190 | log_single_entry(cfg, validator, registry, 'likely missed a block', '😩', slot, COLOR_RED) 191 | 192 | for slot, validator in m.details_missed_blocks_finalized: 193 | log_single_entry(cfg, validator, registry, 'missed a block for real', '😭', slot, COLOR_BOLD_RED) 194 | 195 | if m.details_missed_attestations: 196 | # Only log once per epoch future block proposals. 197 | if current_slot % 32 == SLOT_FOR_MISSED_ATTESTATIONS_PROCESS: 198 | log_multiple_entries(cfg, m.details_missed_attestations, registry, 'missed an attestation', '😞', COLOR_YELLOW) 199 | -------------------------------------------------------------------------------- /eth_validator_watcher/metrics.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from dataclasses import dataclass 4 | 5 | from prometheus_client import Counter, Gauge 6 | 7 | from eth_validator_watcher_ext import fast_compute_validator_metrics, MetricsByLabel 8 | 9 | from .watched_validators import WatchedValidator 10 | 11 | 12 | # This is global because Prometheus metrics don't support registration 13 | # multiple times. This is a workaround for unit tests. 14 | _metrics = None 15 | 16 | 17 | @dataclass 18 | class PrometheusMetrics: 19 | """Define the Prometheus metrics for validator monitoring. 20 | 21 | We sometimes have two declinations of the same metric, one for the 22 | base validator, and one for the stake-scaled validator. Example: 23 | 24 | eth_validator_status_count (base validator metric, absolute) 25 | eth_validator_status_scaled_count (stake-scaled validator metric, relative) 26 | 27 | Args: 28 | None 29 | 30 | Returns: 31 | None 32 | """ 33 | eth_slot: Gauge 34 | eth_epoch: Gauge 35 | eth_current_price_dollars: Gauge 36 | 37 | # Queues 38 | eth_pending_deposits_count: Gauge 39 | eth_pending_deposits_value: Gauge 40 | eth_pending_consolidations_count: Gauge 41 | eth_pending_withdrawals_count: Gauge 42 | 43 | # The scaled version is multiplied by EB/32. 44 | eth_validator_status_count: Gauge 45 | eth_validator_status_scaled_count: Gauge 46 | eth_validator_type_count: Gauge 47 | eth_validator_type_scaled_count: Gauge 48 | 49 | # Those are already stake-scaled 50 | eth_suboptimal_sources_rate: Gauge 51 | eth_suboptimal_targets_rate: Gauge 52 | eth_suboptimal_heads_rate: Gauge 53 | eth_consensus_rewards_rate: Gauge 54 | eth_ideal_consensus_rewards_gwei: Gauge 55 | eth_actual_consensus_rewards_gwei: Gauge 56 | 57 | # The scaled version is multiplied by EB/32. 58 | eth_missed_attestations_count: Gauge 59 | eth_missed_attestations_scaled_count: Gauge 60 | eth_missed_consecutive_attestations_count: Gauge 61 | eth_missed_consecutive_attestations_scaled_count: Gauge 62 | eth_slashed_validators_count: Gauge 63 | eth_missed_duties_at_slot_count: Gauge 64 | eth_missed_duties_at_slot_scaled_count: Gauge 65 | eth_performed_duties_at_slot_count: Gauge 66 | eth_performed_duties_at_slot_scaled_count: Gauge 67 | eth_duties_rate: Gauge 68 | eth_duties_rate_scaled: Gauge 69 | 70 | # Those are already stake-scaled 71 | eth_block_proposals_head_total: Counter 72 | eth_missed_block_proposals_head_total: Counter 73 | eth_block_proposals_finalized_total: Counter 74 | eth_missed_block_proposals_finalized_total: Counter 75 | 76 | eth_future_block_proposals: Gauge 77 | 78 | 79 | def compute_validator_metrics(validators: dict[int, WatchedValidator], slot: int) -> dict[str, MetricsByLabel]: 80 | """Compute the metrics from a dictionary of validators. 81 | 82 | Args: 83 | validators: dict[int, WatchedValidator] 84 | Dictionary of validator index to WatchedValidator objects. 85 | slot: int 86 | Current slot being processed. 87 | 88 | Returns: 89 | dict[str, MetricsByLabel] 90 | Dictionary of metric names to computed metrics by label. 91 | """ 92 | logging.info(f"📊 Computing metrics for {len(validators)} validators") 93 | metrics = fast_compute_validator_metrics(validators, slot) 94 | 95 | for _, v in validators.items(): 96 | v.reset_blocks() 97 | 98 | return metrics 99 | 100 | 101 | def get_prometheus_metrics() -> PrometheusMetrics: 102 | """Get or initialize the Prometheus metrics singleton. 103 | 104 | Args: 105 | None 106 | 107 | Returns: 108 | PrometheusMetrics 109 | The Prometheus metrics singleton instance. 110 | """ 111 | global _metrics 112 | 113 | if _metrics is None: 114 | _metrics = PrometheusMetrics( 115 | eth_slot=Gauge("eth_slot", "Current slot", ["network"]), 116 | eth_epoch=Gauge("eth_epoch", "Current epoch", ["network"]), 117 | eth_current_price_dollars=Gauge("eth_current_price_dollars", "Current price of ETH in USD", ["network"]), 118 | 119 | eth_pending_deposits_count=Gauge("eth_pending_deposits_count", "Pending deposits count sampled every epoch", ['network']), 120 | eth_pending_deposits_value=Gauge("eth_pending_deposits_value", "Pending deposits value sampled every epoch", ['network']), 121 | eth_pending_consolidations_count=Gauge("eth_pending_consolidations_count", "Pending consolidations count sampled every epoch", ['network']), 122 | eth_pending_withdrawals_count=Gauge("eth_pending_withdrawals_count", "Pending withdrawals count sampled every epoch", ['network']), 123 | 124 | eth_validator_status_count=Gauge("eth_validator_status_count", "Validator status count sampled every epoch", ['scope', 'status', 'network']), 125 | eth_validator_status_scaled_count=Gauge("eth_validator_status_scaled_count", "Stake-scaled validator status count sampled every epoch", ['scope', 'status', 'network']), 126 | eth_validator_type_count=Gauge("eth_validator_type_count", "Validator type count sampled every epoch", ['scope', 'type', 'network']), 127 | eth_validator_type_scaled_count=Gauge("eth_validator_type_scaled_count", "Stake-scaled validator type count sampled every epoch", ['scope', 'type', 'network']), 128 | eth_suboptimal_sources_rate=Gauge("eth_suboptimal_sources_rate", "Suboptimal sources rate sampled every epoch", ['scope', 'network']), 129 | eth_suboptimal_targets_rate=Gauge("eth_suboptimal_targets_rate", "Suboptimal targets rate sampled every epoch", ['scope', 'network']), 130 | eth_suboptimal_heads_rate=Gauge("eth_suboptimal_heads_rate", "Suboptimal heads rate sampled every epoch", ['scope', 'network']), 131 | eth_ideal_consensus_rewards_gwei=Gauge("eth_ideal_consensus_rewards_gwei", "Ideal consensus rewards sampled every epoch", ['scope', 'network']), 132 | eth_actual_consensus_rewards_gwei=Gauge("eth_actual_consensus_rewards_gwei", "Actual consensus rewards sampled every epoch", ['scope', 'network']), 133 | eth_consensus_rewards_rate=Gauge("eth_consensus_rewards_rate", "Consensus rewards rate sampled every epoch", ['scope', 'network']), 134 | eth_missed_attestations_count=Gauge("eth_missed_attestations", "Missed attestations in the last epoch", ['scope', 'network']), 135 | eth_missed_attestations_scaled_count=Gauge("eth_missed_attestations_scaled", "Stake-scaled missed attestations in the last epoch", ['scope', 'network']), 136 | eth_missed_consecutive_attestations_count=Gauge("eth_missed_consecutive_attestations", "Missed consecutive attestations in the last two epochs", ['scope', 'network']), 137 | eth_missed_consecutive_attestations_scaled_count=Gauge("eth_missed_consecutive_attestations_scaled", "Stake-scaled missed consecutive attestations in the last two epochs", ['scope', 'network']), 138 | eth_slashed_validators_count=Gauge("eth_slashed_validators", "Slashed validators", ['scope', 'network']), 139 | eth_missed_duties_at_slot_count=Gauge("eth_missed_duties_at_slot", "Missed validator duties in last slot", ['scope', 'network']), 140 | eth_missed_duties_at_slot_scaled_count=Gauge("eth_missed_duties_at_slot_scaled", "Stake-scaled missed validator duties in last slot", ['scope', 'network']), 141 | eth_performed_duties_at_slot_count=Gauge("eth_performed_duties_at_slot", "Performed validator duties in last slot", ['scope', 'network']), 142 | eth_performed_duties_at_slot_scaled_count=Gauge("eth_performed_duties_at_slot_scaled", "Stake-scaled performed validator duties in last slot", ['scope', 'network']), 143 | eth_duties_rate=Gauge("eth_duties_rate", "Duties rate in last slot", ['scope', 'network']), 144 | eth_duties_rate_scaled=Gauge("eth_duties_rate_scaled", "Stake-scaled duties rate in last slot", ['scope', 'network']), 145 | eth_block_proposals_head_total=Counter("eth_block_proposals_head_total", "Total block proposals at head", ['scope', 'network']), 146 | eth_missed_block_proposals_head_total=Counter("eth_missed_block_proposals_head_total", "Total missed block proposals at head", ['scope', 'network']), 147 | eth_block_proposals_finalized_total=Counter("eth_block_proposals_finalized_total", "Total finalized block proposals", ['scope', 'network']), 148 | eth_missed_block_proposals_finalized_total=Counter("eth_missed_block_proposals_finalized_total", "Total missed finalized block proposals", ['scope', 'network']), 149 | eth_future_block_proposals=Gauge("eth_future_block_proposals", "Future block proposals", ['scope', 'network']) 150 | ) 151 | 152 | return _metrics 153 | -------------------------------------------------------------------------------- /eth_validator_watcher/mod.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | namespace py = pybind11; 8 | 9 | static constexpr int kMaxLogging = 5; 10 | static constexpr char kLogLabel[] = "scope:watched"; 11 | 12 | using float64_t = double; 13 | 14 | // Flat structure to allow stupid simple conversions to Python without 15 | // having too-many levels of mental indirections. Processing is shared 16 | // between python (convenience) and cpp (fast). 17 | struct Validator { 18 | // Updated data from the config processing 19 | std::vector labels; 20 | 21 | // Updated data from the rewards processing 22 | bool missed_attestation = false; 23 | bool previous_missed_attestation = false; 24 | bool suboptimal_source = false; 25 | bool suboptimal_target = false; 26 | bool suboptimal_head = false; 27 | float64_t ideal_consensus_reward = 0; 28 | float64_t actual_consensus_reward = 0; 29 | 30 | // Updated data from the duties processing 31 | uint64_t duties_slot = 0; 32 | bool duties_performed_at_slot = false; 33 | 34 | // Updated data from the blocks processing 35 | std::vector missed_blocks; 36 | std::vector missed_blocks_finalized; 37 | std::vector proposed_blocks; 38 | std::vector proposed_blocks_finalized; 39 | std::vector future_blocks_proposal; 40 | 41 | // Updated data from the beacon state processing 42 | std::string consensus_pubkey; 43 | uint64_t consensus_effective_balance = 0; 44 | bool consensus_slashed = false; 45 | uint64_t consensus_index = 0; 46 | std::string consensus_status; 47 | uint64_t consensus_type = 0; 48 | uint64_t consensus_activation_epoch = 0; 49 | 50 | // This is the weight of the validator compared to a 32 ETH 0x01 51 | // validator. 52 | float64_t weight = 0; 53 | }; 54 | 55 | // Same, flat structure approach. This is used to aggregate data from 56 | // all validators by labels. 57 | struct MetricsByLabel { 58 | std::map validator_status_count; 59 | std::map validator_status_scaled_count; 60 | std::map validator_type_count; 61 | std::map validator_type_scaled_count; 62 | 63 | uint64_t suboptimal_source_count = 0; 64 | uint64_t suboptimal_target_count = 0; 65 | uint64_t suboptimal_head_count = 0; 66 | uint64_t optimal_source_count = 0; 67 | uint64_t optimal_target_count = 0; 68 | uint64_t optimal_head_count = 0; 69 | uint64_t validator_slashes = 0; 70 | uint64_t missed_duties_at_slot_count = 0; 71 | float64_t missed_duties_at_slot_scaled_count = 0.0f; 72 | uint64_t performed_duties_at_slot_count = 0; 73 | float64_t performed_duties_at_slot_scaled_count = 0.0f; 74 | float64_t duties_rate = 0.0f; 75 | float64_t duties_rate_scaled = 0.0f; 76 | 77 | float64_t ideal_consensus_reward = 0; 78 | float64_t actual_consensus_reward = 0; 79 | uint64_t missed_attestations_count = 0; 80 | float64_t missed_attestations_scaled_count = 0.0f; 81 | uint64_t missed_consecutive_attestations_count = 0; 82 | float64_t missed_consecutive_attestations_scaled_count = 0.0f; 83 | 84 | uint64_t proposed_blocks = 0; 85 | uint64_t missed_blocks = 0; 86 | uint64_t proposed_blocks_finalized = 0; 87 | uint64_t missed_blocks_finalized = 0; 88 | uint64_t future_blocks_proposal = 0; 89 | 90 | std::vector> details_proposed_blocks; 91 | std::vector> details_missed_blocks; 92 | std::vector> details_missed_blocks_finalized; 93 | std::vector> details_future_blocks; 94 | std::vector details_missed_attestations; 95 | }; 96 | 97 | namespace { 98 | void process_details(const std::string &validator, std::vector slots, std::vector> *out) { 99 | for (const auto& slot: slots) { 100 | if (out->size() >= kMaxLogging) { 101 | break; 102 | } 103 | out->push_back({slot, validator}); 104 | } 105 | } 106 | 107 | void process(uint64_t slot, std::size_t from, std::size_t to, const std::vector &vals, std::map &out) { 108 | for (std::size_t i = from; i < to; i++) { 109 | auto &v = vals[i]; 110 | 111 | for (const auto& label: v.labels) { 112 | MetricsByLabel & m = out[label]; 113 | 114 | m.validator_status_count[v.consensus_status] += 1; 115 | m.validator_status_scaled_count[v.consensus_status] += 1.0 * v.weight; 116 | m.validator_type_count[v.consensus_type] += 1; 117 | m.validator_type_scaled_count[v.consensus_type] += 1.0 * v.weight; 118 | 119 | m.validator_slashes += (v.consensus_slashed == true); 120 | 121 | // Everything below implies to have a validator that is active 122 | // on the beacon chain, this prevents miscounting missed 123 | // attestation for instance. 124 | if (v.consensus_status.find("active") == std::string::npos) { 125 | continue; 126 | } 127 | 128 | m.suboptimal_source_count += int(v.suboptimal_source == true); 129 | m.suboptimal_target_count += int(v.suboptimal_target == true); 130 | m.suboptimal_head_count += int(v.suboptimal_head == true); 131 | m.optimal_source_count += int(v.suboptimal_source == false); 132 | m.optimal_target_count += int(v.suboptimal_target == false); 133 | m.optimal_head_count += int(v.suboptimal_head == false); 134 | 135 | if (slot == v.duties_slot) { 136 | m.performed_duties_at_slot_count += int(v.duties_performed_at_slot == true); 137 | m.performed_duties_at_slot_scaled_count += int(v.duties_performed_at_slot == true) * v.weight; 138 | m.missed_duties_at_slot_count += int(v.duties_performed_at_slot == false); 139 | m.missed_duties_at_slot_scaled_count += int(v.duties_performed_at_slot == false) * v.weight; 140 | } 141 | 142 | m.ideal_consensus_reward += v.ideal_consensus_reward; 143 | m.actual_consensus_reward += v.actual_consensus_reward; 144 | 145 | m.missed_attestations_count += int(v.missed_attestation == true); 146 | m.missed_attestations_scaled_count += int(v.missed_attestation == true) * v.weight; 147 | m.missed_consecutive_attestations_count += int(v.previous_missed_attestation == true && v.missed_attestation == true); 148 | m.missed_consecutive_attestations_scaled_count += int(v.previous_missed_attestation == true && v.missed_attestation == true) * v.weight; 149 | 150 | m.proposed_blocks += v.proposed_blocks.size(); 151 | m.missed_blocks += v.missed_blocks.size(); 152 | m.proposed_blocks_finalized += v.proposed_blocks_finalized.size(); 153 | m.missed_blocks_finalized += v.missed_blocks_finalized.size(); 154 | m.future_blocks_proposal += v.future_blocks_proposal.size(); 155 | 156 | process_details(v.consensus_pubkey, v.proposed_blocks, &m.details_proposed_blocks); 157 | process_details(v.consensus_pubkey, v.missed_blocks, &m.details_missed_blocks); 158 | process_details(v.consensus_pubkey, v.missed_blocks_finalized, &m.details_missed_blocks_finalized); 159 | process_details(v.consensus_pubkey, v.future_blocks_proposal, &m.details_future_blocks); 160 | if (v.missed_attestation && m.details_missed_attestations.size() < kMaxLogging) { 161 | m.details_missed_attestations.push_back(v.consensus_pubkey); 162 | } 163 | } 164 | } 165 | } 166 | 167 | void merge_details(const std::vector> &details, std::vector> *out) { 168 | for (const auto& detail: details) { 169 | if (out->size() >= kMaxLogging) { 170 | break; 171 | } 172 | out->push_back(detail); 173 | } 174 | } 175 | 176 | void merge(const std::vector> &thread_metrics, std::map *out) { 177 | for (const auto& thread_metric: thread_metrics) { 178 | for (const auto& [label, metric]: thread_metric) { 179 | MetricsByLabel & m = (*out)[label]; 180 | 181 | for (const auto& [status, count]: metric.validator_status_count) { 182 | m.validator_status_count[status] += count; 183 | } 184 | for (const auto& [status, count]: metric.validator_status_scaled_count) { 185 | m.validator_status_scaled_count[status] += count; 186 | } 187 | for (const auto& [type, count]: metric.validator_type_count) { 188 | m.validator_type_count[type] += count; 189 | } 190 | for (const auto& [type, count]: metric.validator_type_scaled_count) { 191 | m.validator_type_scaled_count[type] += count; 192 | } 193 | 194 | m.suboptimal_source_count += metric.suboptimal_source_count; 195 | m.suboptimal_target_count += metric.suboptimal_target_count; 196 | m.suboptimal_head_count += metric.suboptimal_head_count; 197 | m.optimal_source_count += metric.optimal_source_count; 198 | m.optimal_target_count += metric.optimal_target_count; 199 | m.optimal_head_count += metric.optimal_head_count; 200 | m.validator_slashes += metric.validator_slashes; 201 | m.missed_duties_at_slot_count += metric.missed_duties_at_slot_count; 202 | m.missed_duties_at_slot_scaled_count += metric.missed_duties_at_slot_scaled_count; 203 | m.performed_duties_at_slot_count += metric.performed_duties_at_slot_count; 204 | m.performed_duties_at_slot_scaled_count += metric.performed_duties_at_slot_scaled_count; 205 | 206 | m.ideal_consensus_reward += metric.ideal_consensus_reward; 207 | m.actual_consensus_reward += metric.actual_consensus_reward; 208 | m.missed_attestations_count += metric.missed_attestations_count; 209 | m.missed_attestations_scaled_count += metric.missed_attestations_scaled_count; 210 | m.missed_consecutive_attestations_count += metric.missed_consecutive_attestations_count; 211 | m.missed_consecutive_attestations_scaled_count += metric.missed_consecutive_attestations_scaled_count; 212 | 213 | m.proposed_blocks += metric.proposed_blocks; 214 | m.missed_blocks += metric.missed_blocks; 215 | m.proposed_blocks_finalized += metric.proposed_blocks_finalized; 216 | m.missed_blocks_finalized += metric.missed_blocks_finalized; 217 | m.future_blocks_proposal += metric.future_blocks_proposal; 218 | 219 | merge_details(metric.details_proposed_blocks, &m.details_proposed_blocks); 220 | merge_details(metric.details_missed_blocks, &m.details_missed_blocks); 221 | merge_details(metric.details_missed_blocks_finalized, &m.details_missed_blocks_finalized); 222 | merge_details(metric.details_future_blocks, &m.details_future_blocks); 223 | 224 | for (const auto& missed_attestation: metric.details_missed_attestations) { 225 | if (m.details_missed_attestations.size() < kMaxLogging) { 226 | m.details_missed_attestations.push_back(missed_attestation); 227 | } 228 | } 229 | } 230 | } 231 | 232 | // Compute the duties rate once per label. 233 | for (auto& [label, o]: *out) { 234 | const float64_t total = o.missed_duties_at_slot_count + o.performed_duties_at_slot_count; 235 | const float64_t total_scaled = o.missed_duties_at_slot_scaled_count + o.performed_duties_at_slot_scaled_count; 236 | 237 | // Here we assume that if we don't have any duties process, the 238 | // duties were performed. 239 | o.duties_rate = total ? float64_t(o.performed_duties_at_slot_count) / total : 1.0f; 240 | o.duties_rate_scaled = total_scaled ? float64_t(o.performed_duties_at_slot_scaled_count) / total_scaled : 1.0f; 241 | } 242 | 243 | } 244 | 245 | } // anonymous namespace 246 | 247 | PYBIND11_MODULE(eth_validator_watcher_ext, m) { 248 | 249 | py::class_(m, "Validator") 250 | .def(py::init<>()) 251 | .def_readwrite("labels", &Validator::labels) 252 | .def_readwrite("missed_attestation", &Validator::missed_attestation) 253 | .def_readwrite("previous_missed_attestation", &Validator::previous_missed_attestation) 254 | .def_readwrite("suboptimal_source", &Validator::suboptimal_source) 255 | .def_readwrite("suboptimal_target", &Validator::suboptimal_target) 256 | .def_readwrite("suboptimal_head", &Validator::suboptimal_head) 257 | .def_readwrite("ideal_consensus_reward", &Validator::ideal_consensus_reward) 258 | .def_readwrite("actual_consensus_reward", &Validator::actual_consensus_reward) 259 | .def_readwrite("duties_slot", &Validator::duties_slot) 260 | .def_readwrite("duties_performed_at_slot", &Validator::duties_performed_at_slot) 261 | .def_readwrite("missed_blocks", &Validator::missed_blocks) 262 | .def_readwrite("missed_blocks_finalized", &Validator::missed_blocks_finalized) 263 | .def_readwrite("proposed_blocks", &Validator::proposed_blocks) 264 | .def_readwrite("proposed_blocks_finalized", &Validator::proposed_blocks_finalized) 265 | .def_readwrite("future_blocks_proposal", &Validator::future_blocks_proposal) 266 | .def_readwrite("consensus_pubkey", &Validator::consensus_pubkey) 267 | .def_readwrite("consensus_effective_balance", &Validator::consensus_effective_balance) 268 | .def_readwrite("consensus_slashed", &Validator::consensus_slashed) 269 | .def_readwrite("consensus_index", &Validator::consensus_index) 270 | .def_readwrite("consensus_status", &Validator::consensus_status) 271 | .def_readwrite("consensus_activation_epoch", &Validator::consensus_activation_epoch) 272 | .def_readwrite("consensus_type", &Validator::consensus_type) 273 | .def_readwrite("weight", &Validator::weight); 274 | 275 | py::class_(m, "MetricsByLabel") 276 | .def(py::init<>()) 277 | .def_readwrite("validator_status_count", &MetricsByLabel::validator_status_count) 278 | .def_readwrite("validator_status_scaled_count", &MetricsByLabel::validator_status_scaled_count) 279 | .def_readwrite("validator_type_count", &MetricsByLabel::validator_type_count) 280 | .def_readwrite("validator_type_scaled_count", &MetricsByLabel::validator_type_scaled_count) 281 | .def_readwrite("suboptimal_source_count", &MetricsByLabel::suboptimal_source_count) 282 | .def_readwrite("suboptimal_target_count", &MetricsByLabel::suboptimal_target_count) 283 | .def_readwrite("suboptimal_head_count", &MetricsByLabel::suboptimal_head_count) 284 | .def_readwrite("missed_duties_at_slot_count", &MetricsByLabel::missed_duties_at_slot_count) 285 | .def_readwrite("missed_duties_at_slot_scaled_count", &MetricsByLabel::missed_duties_at_slot_scaled_count) 286 | .def_readwrite("performed_duties_at_slot_count", &MetricsByLabel::performed_duties_at_slot_count) 287 | .def_readwrite("performed_duties_at_slot_scaled_count", &MetricsByLabel::performed_duties_at_slot_scaled_count) 288 | .def_readwrite("duties_rate", &MetricsByLabel::duties_rate) 289 | .def_readwrite("duties_rate_scaled", &MetricsByLabel::duties_rate_scaled) 290 | .def_readwrite("suboptimal_head_count", &MetricsByLabel::suboptimal_head_count) 291 | .def_readwrite("optimal_source_count", &MetricsByLabel::optimal_source_count) 292 | .def_readwrite("optimal_target_count", &MetricsByLabel::optimal_target_count) 293 | .def_readwrite("optimal_head_count", &MetricsByLabel::optimal_head_count) 294 | .def_readwrite("validator_slashes", &MetricsByLabel::validator_slashes) 295 | .def_readwrite("ideal_consensus_reward", &MetricsByLabel::ideal_consensus_reward) 296 | .def_readwrite("actual_consensus_reward", &MetricsByLabel::actual_consensus_reward) 297 | .def_readwrite("missed_attestations_count", &MetricsByLabel::missed_attestations_count) 298 | .def_readwrite("missed_attestations_scaled_count", &MetricsByLabel::missed_attestations_scaled_count) 299 | .def_readwrite("missed_consecutive_attestations_count", &MetricsByLabel::missed_consecutive_attestations_count) 300 | .def_readwrite("missed_consecutive_attestations_scaled_count", &MetricsByLabel::missed_consecutive_attestations_scaled_count) 301 | .def_readwrite("proposed_blocks", &MetricsByLabel::proposed_blocks) 302 | .def_readwrite("missed_blocks", &MetricsByLabel::missed_blocks) 303 | .def_readwrite("proposed_blocks_finalized", &MetricsByLabel::proposed_blocks_finalized) 304 | .def_readwrite("missed_blocks_finalized", &MetricsByLabel::missed_blocks_finalized) 305 | .def_readwrite("future_blocks_proposal", &MetricsByLabel::future_blocks_proposal) 306 | .def_readwrite("details_proposed_blocks", &MetricsByLabel::details_proposed_blocks) 307 | .def_readwrite("details_missed_blocks", &MetricsByLabel::details_missed_blocks) 308 | .def_readwrite("details_missed_blocks_finalized", &MetricsByLabel::details_missed_blocks_finalized) 309 | .def_readwrite("details_future_blocks", &MetricsByLabel::details_future_blocks) 310 | .def_readwrite("details_missed_attestations", &MetricsByLabel::details_missed_attestations); 311 | 312 | m.def("fast_compute_validator_metrics", [](const py::dict& pyvals, uint64_t slot) { 313 | std::vector vals; 314 | vals.reserve(pyvals.size()); 315 | for (auto& pyval: pyvals) { 316 | vals.push_back(pyval.second.attr("_v").cast()); 317 | } 318 | 319 | auto n = std::thread::hardware_concurrency(); 320 | 321 | std::size_t chunk = (vals.size() / n) + 1; 322 | std::vector threads; 323 | std::vector> thread_metrics(n); 324 | 325 | for (size_t i = 0; i < n; i++) { 326 | threads.push_back(std::thread([slot, i, chunk, &vals, &thread_metrics] { 327 | std::size_t from = i * chunk; 328 | std::size_t to = std::min(from + chunk, vals.size()); 329 | process(slot, from, to, vals, thread_metrics[i]); 330 | })); 331 | } 332 | 333 | for (auto& thread: threads) { 334 | thread.join(); 335 | } 336 | 337 | std::map metrics; 338 | merge(thread_metrics, &metrics); 339 | 340 | py::dict pymetrics; 341 | for (const auto& [label, metric]: metrics) { 342 | pymetrics[py::str(label)] = metric; 343 | } 344 | 345 | return pymetrics; 346 | }); 347 | } 348 | -------------------------------------------------------------------------------- /eth_validator_watcher/models.py: -------------------------------------------------------------------------------- 1 | """Pydantic models for Ethereum validator watcher data structures.""" 2 | 3 | from enum import StrEnum 4 | 5 | from pydantic import BaseModel 6 | 7 | 8 | class Validators(BaseModel): 9 | """Model for validator data from the beacon chain. 10 | 11 | Args: 12 | None 13 | 14 | Returns: 15 | None 16 | """ 17 | class DataItem(BaseModel): 18 | class StatusEnum(StrEnum): 19 | pendingInitialized = "pending_initialized" 20 | pendingQueued = "pending_queued" 21 | activeOngoing = "active_ongoing" 22 | activeExiting = "active_exiting" 23 | activeSlashed = "active_slashed" 24 | exitedUnslashed = "exited_unslashed" 25 | exitedSlashed = "exited_slashed" 26 | withdrawalPossible = "withdrawal_possible" 27 | withdrawalDone = "withdrawal_done" 28 | 29 | class Validator(BaseModel): 30 | pubkey: str 31 | effective_balance: int 32 | slashed: bool 33 | activation_epoch: int 34 | withdrawal_credentials: str 35 | 36 | index: int 37 | status: StatusEnum 38 | 39 | validator: Validator 40 | 41 | data: list[DataItem] 42 | 43 | 44 | class Genesis(BaseModel): 45 | """Model for beacon chain genesis data. 46 | 47 | Args: 48 | None 49 | 50 | Returns: 51 | None 52 | """ 53 | class Data(BaseModel): 54 | genesis_time: int 55 | 56 | data: Data 57 | 58 | 59 | class Spec(BaseModel): 60 | """Model for beacon chain specification data. 61 | 62 | Args: 63 | None 64 | 65 | Returns: 66 | None 67 | """ 68 | class Data(BaseModel): 69 | SECONDS_PER_SLOT: int 70 | SLOTS_PER_EPOCH: int 71 | 72 | data: Data 73 | 74 | 75 | class Header(BaseModel): 76 | """Model for block header data from the beacon chain. 77 | 78 | Args: 79 | None 80 | 81 | Returns: 82 | None 83 | """ 84 | class Data(BaseModel): 85 | class Header(BaseModel): 86 | class Message(BaseModel): 87 | slot: int 88 | 89 | message: Message 90 | 91 | header: Header 92 | 93 | data: Data 94 | 95 | 96 | class Block(BaseModel): 97 | """Model for block data from the beacon chain. 98 | 99 | Args: 100 | None 101 | 102 | Returns: 103 | None 104 | """ 105 | class Data(BaseModel): 106 | class Message(BaseModel): 107 | class Body(BaseModel): 108 | class Attestation(BaseModel): 109 | class Data(BaseModel): 110 | slot: int 111 | index: int 112 | 113 | aggregation_bits: str 114 | data: Data 115 | 116 | class ExecutionPayload(BaseModel): 117 | fee_recipient: str 118 | block_hash: str 119 | 120 | attestations: list[Attestation] 121 | execution_payload: ExecutionPayload 122 | 123 | slot: int 124 | proposer_index: int 125 | body: Body 126 | 127 | message: Message 128 | 129 | data: Data 130 | 131 | 132 | class ProposerDuties(BaseModel): 133 | """Model for validator proposer duties data. 134 | 135 | Args: 136 | None 137 | 138 | Returns: 139 | None 140 | """ 141 | class Data(BaseModel): 142 | pubkey: str 143 | validator_index: int 144 | slot: int 145 | 146 | dependent_root: str 147 | data: list[Data] 148 | 149 | 150 | class ValidatorsLivenessResponse(BaseModel): 151 | """Model for validator liveness data. 152 | 153 | Args: 154 | None 155 | 156 | Returns: 157 | None 158 | """ 159 | class Data(BaseModel): 160 | index: int 161 | is_live: bool 162 | 163 | data: list[Data] 164 | 165 | 166 | class SlotWithStatus(BaseModel): 167 | """Model for slot data with missed status. 168 | 169 | Args: 170 | None 171 | 172 | Returns: 173 | None 174 | """ 175 | number: int 176 | missed: bool 177 | 178 | 179 | class CoinbaseTrade(BaseModel): 180 | """Model for Coinbase trade data. 181 | 182 | Args: 183 | None 184 | 185 | Returns: 186 | None 187 | """ 188 | time: str 189 | trade_id: int 190 | price: float 191 | size: float 192 | side: str 193 | 194 | 195 | class BlockIdentierType(StrEnum): 196 | """Enumeration of block identifier types. 197 | 198 | Args: 199 | None 200 | 201 | Returns: 202 | None 203 | """ 204 | HEAD = "head" 205 | GENESIS = "genesis" 206 | FINALIZED = "finalized" 207 | 208 | 209 | class Rewards(BaseModel): 210 | """Model for validator reward data. 211 | 212 | Args: 213 | None 214 | 215 | Returns: 216 | None 217 | """ 218 | class Data(BaseModel): 219 | class IdealReward(BaseModel): 220 | effective_balance: int 221 | source: int 222 | target: int 223 | head: int 224 | 225 | class TotalReward(BaseModel): 226 | validator_index: int 227 | source: int 228 | target: int 229 | head: int 230 | 231 | ideal_rewards: list[IdealReward] 232 | total_rewards: list[TotalReward] 233 | 234 | data: Data 235 | 236 | 237 | class Committees(BaseModel): 238 | """Model for committee assignment data. 239 | 240 | Args: 241 | None 242 | 243 | Returns: 244 | None 245 | """ 246 | class Data(BaseModel): 247 | index: int 248 | slot: int 249 | validators: list[int] 250 | 251 | data: list[Data] 252 | 253 | 254 | class Attestations(BaseModel): 255 | """Model for attestation data from blocks. 256 | 257 | Args: 258 | None 259 | 260 | Returns: 261 | None 262 | """ 263 | class SignedAttestationData(BaseModel): 264 | class AttestationData(BaseModel): 265 | slot: int 266 | 267 | aggregation_bits: str 268 | committee_bits: str 269 | data: AttestationData 270 | 271 | data: list[SignedAttestationData] 272 | 273 | 274 | class PendingDeposits(BaseModel): 275 | """Model for pending deposit data. 276 | Args: 277 | None 278 | Returns: 279 | None 280 | """ 281 | 282 | class PendingDepositData(BaseModel): 283 | pubkey: str 284 | withdrawal_credentials: str 285 | amount: int 286 | slot: int 287 | 288 | data: list[PendingDepositData] 289 | 290 | 291 | class PendingConsolidations(BaseModel): 292 | """Model for pending consolidation data. 293 | 294 | Args: 295 | None 296 | Returns: 297 | None 298 | """ 299 | class PendingConsolidationData(BaseModel): 300 | source_index: int 301 | target_index: int 302 | 303 | data: list[PendingConsolidationData] 304 | 305 | 306 | class PendingWithdrawals(BaseModel): 307 | """Model for pending withdrawal data. 308 | 309 | Args: 310 | None 311 | Returns: 312 | None 313 | """ 314 | class PendingWithdrawalData(BaseModel): 315 | validator_index: int 316 | amount: int 317 | 318 | data: list[PendingWithdrawalData] 319 | -------------------------------------------------------------------------------- /eth_validator_watcher/proposer_schedule.py: -------------------------------------------------------------------------------- 1 | """This module contains facilities to keep track of which validator proposes blocks. 2 | """ 3 | 4 | 5 | from .beacon import Beacon 6 | from .models import Spec 7 | 8 | 9 | class ProposerSchedule: 10 | """Helper class to keep track of which validator proposes blocks. 11 | 12 | We need to keep track of all slots since the last finalization and 13 | up to the end of the next epoch. 14 | """ 15 | 16 | def __init__(self, spec: Spec): 17 | self._spec = spec 18 | self._last_slot = None 19 | self._schedule = dict() 20 | 21 | def get_proposer(self, slot: int) -> int: 22 | """Get the proposer for a slot. 23 | 24 | Args: 25 | slot: int 26 | The slot to get the proposer for. 27 | 28 | Returns: 29 | int: The validator index of the proposer, or None if not found. 30 | """ 31 | return self._schedule.get(slot, None) 32 | 33 | def get_future_proposals(self, slot: int) -> dict[int, int]: 34 | """Get all future proposals after the given slot. 35 | 36 | Args: 37 | slot: int 38 | The current slot to get proposals after. 39 | 40 | Returns: 41 | dict[int, int]: A dictionary mapping slots to validator indices. 42 | """ 43 | return {k: v for k, v in self._schedule.items() if k > slot} 44 | 45 | def epoch(self, slot: int) -> int: 46 | """Convert a slot to its epoch. 47 | 48 | Args: 49 | slot: int 50 | The slot to convert. 51 | 52 | Returns: 53 | int: The epoch number containing this slot. 54 | """ 55 | return slot // self._spec.data.SLOTS_PER_EPOCH 56 | 57 | def update(self, beacon: Beacon, slot: int) -> None: 58 | """Update the proposer schedules. 59 | 60 | Updates both the head and finalized proposer schedules. 61 | 62 | Args: 63 | beacon: Beacon 64 | The beacon client to fetch data from. 65 | slot: int 66 | The current slot. 67 | 68 | Returns: 69 | None 70 | """ 71 | # Current slots & future proposals. 72 | 73 | # There is a case to handle here: on the very first slot of an 74 | # epoch, some beacons will return 404 and will only expose the 75 | # schedule on the next slot. 76 | 77 | epoch = self.epoch(slot) 78 | if slot not in self._schedule: 79 | duties = beacon.get_proposer_duties(epoch) 80 | for duty in duties.data: 81 | self._schedule[duty.slot] = duty.validator_index 82 | if (slot + self._spec.data.SLOTS_PER_EPOCH) not in self._schedule: 83 | duties = beacon.get_proposer_duties(epoch + 1) 84 | for duty in duties.data: 85 | self._schedule[duty.slot] = duty.validator_index 86 | 87 | def clear(self, cutoff: int) -> None: 88 | """Clear old slots from the schedules. 89 | 90 | Args: 91 | cutoff: int 92 | The slot to clear up to. 93 | 94 | Returns: 95 | None 96 | """ 97 | self._schedule = {k: v for k, v in self._schedule.items() if k > cutoff} 98 | -------------------------------------------------------------------------------- /eth_validator_watcher/queues.py: -------------------------------------------------------------------------------- 1 | from .beacon import Beacon 2 | 3 | 4 | def get_pending_deposits(beacon: Beacon) -> tuple[int, int]: 5 | """Returns deposits information from the beacon chain. 6 | 7 | Args: 8 | beacon: Beacon 9 | The beacon client to fetch data from. 10 | Returns: 11 | tuple[int, int] 12 | Number of deposits and the total amount in Gwei. 13 | """ 14 | deposits = beacon.get_pending_deposits() 15 | 16 | total = 0 17 | count = 0 18 | for deposit in deposits.data: 19 | total += deposit.amount 20 | count += 1 21 | 22 | return count, total 23 | 24 | 25 | def get_pending_consolidations(beacon: Beacon) -> int: 26 | """Returns the number of pending consolidations from the beacon chain. 27 | 28 | Args: 29 | beacon: Beacon 30 | The beacon client to fetch data from. 31 | Returns: 32 | int 33 | Number of pending consolidations. 34 | """ 35 | consolidations = beacon.get_pending_consolidations() 36 | return len(consolidations.data) 37 | 38 | 39 | def get_pending_withdrawals(beacon: Beacon) -> int: 40 | """Returns the number of pending withdrawals from the beacon chain. 41 | 42 | Args: 43 | beacon: Beacon 44 | The beacon client to fetch data from. 45 | Returns: 46 | int 47 | Number of pending withdrawals. 48 | """ 49 | withdrawals = beacon.get_pending_withdrawals() 50 | return len(withdrawals.data) 51 | -------------------------------------------------------------------------------- /eth_validator_watcher/rewards.py: -------------------------------------------------------------------------------- 1 | """Contains functions to handle rewards calculation""" 2 | 3 | from .models import Rewards 4 | from .watched_validators import WatchedValidators 5 | 6 | 7 | def process_rewards(validators: WatchedValidators, rewards: Rewards) -> None: 8 | """Processes rewards for all validators. 9 | 10 | Args: 11 | validators: WatchedValidators 12 | The registry of validators being watched. 13 | rewards: Rewards 14 | The rewards data to process. 15 | 16 | Returns: 17 | None 18 | """ 19 | ideal_by_eb: dict[int, Rewards.Data.IdealReward] = {} 20 | for ideal_reward in rewards.data.ideal_rewards: 21 | ideal_by_eb[ideal_reward.effective_balance] = ideal_reward 22 | 23 | for reward in rewards.data.total_rewards: 24 | validator = validators.get_validator_by_index(reward.validator_index) 25 | if not validator: 26 | continue 27 | 28 | ideal = ideal_by_eb.get(validator.effective_balance) 29 | if not ideal: 30 | continue 31 | 32 | validator.process_rewards(ideal, reward) 33 | -------------------------------------------------------------------------------- /eth_validator_watcher/utils.py: -------------------------------------------------------------------------------- 1 | 2 | # Slots at which processing is performed. 3 | SLOT_FOR_CONFIG_RELOAD = 15 4 | SLOT_FOR_MISSED_ATTESTATIONS_PROCESS = 16 5 | SLOT_FOR_REWARDS_PROCESS = 17 6 | 7 | # Default set of existing scopes. 8 | LABEL_SCOPE_ALL_NETWORK = "scope:all-network" 9 | LABEL_SCOPE_WATCHED = "scope:watched" 10 | LABEL_SCOPE_NETWORK = "scope:network" 11 | 12 | 13 | def pct(a: int, b: int, inclusive: bool = False) -> float: 14 | """Helper function to calculate the percentage of a over b. 15 | 16 | Args: 17 | a: int 18 | Numerator value. 19 | b: int 20 | Denominator value. 21 | inclusive: bool 22 | If True, uses b as total; if False, uses a+b as total. 23 | 24 | Returns: 25 | float: Percentage value (0-100.0). 26 | """ 27 | total = a + b if not inclusive else b 28 | if total == 0: 29 | return 0.0 30 | return float(a / total) * 100.0 31 | -------------------------------------------------------------------------------- /eth_validator_watcher/watched_validators.py: -------------------------------------------------------------------------------- 1 | """Classes and functions for managing watched validators.""" 2 | 3 | from typing import Optional 4 | 5 | from eth_validator_watcher_ext import Validator 6 | from .config import Config, WatchedKeyConfig 7 | from .models import Validators, ValidatorsLivenessResponse, Rewards 8 | from .utils import LABEL_SCOPE_ALL_NETWORK, LABEL_SCOPE_WATCHED, LABEL_SCOPE_NETWORK 9 | 10 | 11 | def normalized_public_key(pubkey: str) -> str: 12 | """Normalize a validator public key by removing 0x prefix and lowercasing. 13 | 14 | Args: 15 | pubkey: str 16 | Public key to normalize. 17 | 18 | Returns: 19 | str 20 | Normalized public key. 21 | """ 22 | if pubkey.startswith('0x'): 23 | pubkey = pubkey[2:] 24 | return pubkey.lower() 25 | 26 | 27 | class WatchedValidator: 28 | """Watched validator abstraction. 29 | 30 | This is a wrapper around the C++ validator object which holds the 31 | state of a validator. 32 | 33 | Args: 34 | None 35 | 36 | Returns: 37 | None 38 | """ 39 | 40 | def __init__(self): 41 | # State is wrapped in a C++ object so we can perform efficient 42 | # operations without holding the GIL. 43 | # 44 | # We need to be careful when dealing with _v as modifications 45 | # can only be performed using explicit copies (i.e: do not 46 | # call append() on a list but rather create a new list with 47 | # the new element). 48 | self._v = Validator() 49 | 50 | # This gets overriden by process_config if the validator is watched. 51 | self._v.labels: Optional[list[str]] = [LABEL_SCOPE_ALL_NETWORK, LABEL_SCOPE_NETWORK] 52 | 53 | @property 54 | def effective_balance(self) -> int: 55 | """Get the effective balance of the validator. 56 | 57 | Args: 58 | None 59 | 60 | Returns: 61 | int 62 | The effective balance of the validator in Gwei. 63 | """ 64 | return self._v.consensus_effective_balance 65 | 66 | @property 67 | def labels(self) -> list[str]: 68 | """Get the labels for the validator. 69 | 70 | Args: 71 | None 72 | 73 | Returns: 74 | list[str] 75 | List of labels associated with this validator. 76 | """ 77 | return self._v.labels 78 | 79 | def process_config(self, config: WatchedKeyConfig): 80 | """Process a new configuration for this validator. 81 | 82 | Args: 83 | config: WatchedKeyConfig 84 | New configuration for this validator. 85 | 86 | Returns: 87 | None 88 | """ 89 | # Even if there is no label in the config, we consider the 90 | # validator as watched. This method is only called for 91 | # validators that are watched. 92 | labels = [LABEL_SCOPE_ALL_NETWORK, LABEL_SCOPE_WATCHED] 93 | if config.labels: 94 | labels = labels + config.labels 95 | 96 | self._v.labels = labels 97 | 98 | def process_epoch(self, validator: Validators.DataItem): 99 | """Process validator state for a new epoch. 100 | 101 | Args: 102 | validator: Validators.DataItem 103 | Validator beacon state data. 104 | 105 | Returns: 106 | None 107 | """ 108 | self._v.consensus_pubkey = validator.validator.pubkey 109 | self._v.consensus_effective_balance = validator.validator.effective_balance 110 | self._v.weight = validator.validator.effective_balance / 32_000_000_000 111 | self._v.consensus_slashed = validator.validator.slashed 112 | self._v.consensus_index = validator.index 113 | self._v.consensus_status = validator.status 114 | self._v.consensus_activation_epoch = validator.validator.activation_epoch 115 | self._v.consensus_type = int(validator.validator.withdrawal_credentials[2:4], 16) 116 | 117 | def process_liveness(self, liveness: ValidatorsLivenessResponse.Data, current_epoch: int): 118 | """Processes liveness data. 119 | 120 | Args: 121 | liveness: ValidatorsLivenessResponse.Data 122 | Validator liveness data. 123 | current_epoch: int 124 | Current epoch. 125 | """ 126 | # Because we ask for the liveness of the previous epoch, we 127 | # need to dismiss validators that weren't activated yet at 128 | # that time to prevent false positive. 129 | if (current_epoch - 1) >= self._v.consensus_activation_epoch: 130 | self._v.previous_missed_attestation = self._v.missed_attestation 131 | self._v.missed_attestation = not liveness.is_live 132 | 133 | def process_rewards(self, ideal: Rewards.Data.IdealReward, reward: Rewards.Data.TotalReward): 134 | """Process validator rewards data. 135 | 136 | Args: 137 | ideal: Rewards.Data.IdealReward 138 | Ideal rewards that could have been earned. 139 | reward: Rewards.Data.TotalReward 140 | Actual rewards earned by the validator. 141 | 142 | Returns: 143 | None 144 | """ 145 | self._v.suboptimal_source = reward.source != ideal.source 146 | self._v.suboptimal_target = reward.target != ideal.target 147 | self._v.suboptimal_head = reward.head != ideal.head 148 | 149 | self._v.ideal_consensus_reward = ideal.source + ideal.target + ideal.head 150 | self._v.actual_consensus_reward = reward.source + reward.target + reward.head 151 | 152 | def process_duties(self, slot: int, performed: bool): 153 | """Process a validator attestation duty. 154 | 155 | Args: 156 | slot: int 157 | Slot for which there is or is not an attestation for the validator. 158 | performed: bool 159 | Whether or not the validator attested in this slot. 160 | 161 | Returns: 162 | None 163 | """ 164 | self._v.duties_slot = slot 165 | self._v.duties_performed_at_slot = performed 166 | 167 | def process_block(self, slot: int, has_block: bool): 168 | """Processes a block proposal. 169 | 170 | Args: 171 | slot: int 172 | Slot of the block proposal. 173 | has_block: bool 174 | Whether the block was found (True) or missed (False). 175 | """ 176 | if has_block: 177 | self._v.proposed_blocks = self._v.proposed_blocks + [slot] 178 | else: 179 | self._v.missed_blocks = self._v.missed_blocks + [slot] 180 | 181 | def process_block_finalized(self, slot: int, has_block: bool): 182 | """Processes a finalized block proposal. 183 | 184 | Args: 185 | slot: int 186 | Slot of the block proposal. 187 | has_block: bool 188 | Whether the block was found (True) or missed (False). 189 | """ 190 | if has_block: 191 | self._v.proposed_blocks_finalized = self._v.proposed_blocks_finalized + [slot] 192 | else: 193 | self._v.missed_blocks_finalized = self._v.missed_blocks_finalized + [slot] 194 | 195 | def process_future_block(self, slot: int): 196 | """Process a future block proposal assignment. 197 | 198 | Args: 199 | slot: int 200 | Slot of the future block proposal. 201 | 202 | Returns: 203 | None 204 | """ 205 | self._v.future_blocks_proposal = self._v.future_blocks_proposal + [slot] 206 | 207 | def reset_blocks(self): 208 | """Reset the block counters for the next run. 209 | 210 | Args: 211 | None 212 | 213 | Returns: 214 | None 215 | """ 216 | self._v.missed_blocks = [] 217 | self._v.missed_blocks_finalized = [] 218 | self._v.proposed_blocks = [] 219 | self._v.proposed_blocks_finalized = [] 220 | self._v.future_blocks_proposal = [] 221 | 222 | 223 | class WatchedValidators: 224 | """Registry and manager for watched validators. 225 | 226 | Provides facilities to retrieve a validator by index or public 227 | key. This needs to be efficient both in terms of CPU and memory as 228 | there are about ~1 million validators on the network. 229 | 230 | Args: 231 | None 232 | 233 | Returns: 234 | None 235 | """ 236 | 237 | def __init__(self): 238 | self._validators: dict[int, WatchedValidator] = {} 239 | self._pubkey_to_index: dict[str, int] = {} 240 | 241 | self.config_initialized = False 242 | 243 | def get_validator_by_index(self, index: int) -> Optional[WatchedValidator]: 244 | """Get a validator by index. 245 | 246 | Args: 247 | index: int 248 | Index of the validator to retrieve. 249 | 250 | Returns: 251 | Optional[WatchedValidator]: The validator with the given index, or None if not found. 252 | """ 253 | return self._validators.get(index) 254 | 255 | def get_validator_by_pubkey(self, pubkey: str) -> Optional[WatchedValidator]: 256 | """Get a validator by public key. 257 | 258 | Args: 259 | pubkey: str 260 | Public key of the validator to retrieve. 261 | 262 | Returns: 263 | Optional[WatchedValidator]: The validator with the given public key, or None if not found. 264 | """ 265 | index = self._pubkey_to_index.get(normalized_public_key(pubkey)) 266 | if index is None: 267 | return None 268 | return self._validators.get(index) 269 | 270 | def get_indexes(self) -> list[int]: 271 | """Get all validator indexes. 272 | 273 | Returns: 274 | list[int]: A list of all validator indices in the registry. 275 | """ 276 | return list(self._validators.keys()) 277 | 278 | def get_validators(self) -> dict[int, WatchedValidator]: 279 | """Get all validators in the registry. 280 | 281 | Returns: 282 | dict[int, WatchedValidator]: A dictionary mapping validator indices to WatchedValidator objects. 283 | """ 284 | return self._validators 285 | 286 | def process_config(self, config: Config): 287 | """Process a configuration update for watched validators. 288 | 289 | Args: 290 | config: Config 291 | Updated configuration containing watched keys. 292 | 293 | Returns: 294 | None 295 | """ 296 | for item in config.watched_keys: 297 | index = self._pubkey_to_index.get(normalized_public_key(item.public_key), None) 298 | if index: 299 | validator = self._validators.get(index) 300 | if validator: 301 | validator.process_config(item) 302 | 303 | self.config_initialized = True 304 | 305 | def process_epoch(self, validators: Validators): 306 | """Process validator state data for a new epoch. 307 | 308 | Args: 309 | validators: Validators 310 | New validator state for the epoch from the beacon chain. 311 | 312 | Returns: 313 | None 314 | """ 315 | for item in validators.data: 316 | validator = self._validators.get(item.index) 317 | if validator is None: 318 | validator = WatchedValidator() 319 | self._validators[item.index] = validator 320 | self._pubkey_to_index[normalized_public_key(item.validator.pubkey)] = item.index 321 | 322 | validator.process_epoch(item) 323 | 324 | def process_liveness(self, liveness: ValidatorsLivenessResponse, current_epoch: int): 325 | """Process validator liveness data. 326 | 327 | Args: 328 | liveness: ValidatorsLivenessResponse 329 | Liveness data from the beacon chain. 330 | current_epoch: int 331 | Current epoch being processed. 332 | 333 | Returns: 334 | None 335 | """ 336 | for item in liveness.data: 337 | validator = self._validators.get(item.index) 338 | if validator: 339 | validator.process_liveness(item, current_epoch) 340 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | _default: 2 | @just --list --unsorted 3 | 4 | # Run unit tests 5 | test specific_test='': 6 | uv run pytest --exitfirst -v -k '{{ specific_test }}' --tb=short 7 | 8 | # Run linter 9 | lint: 10 | uv run flake8 eth_validator_watcher tests --ignore=E501 11 | 12 | # Local development 13 | dev: 14 | uv pip install -e . 15 | uv run eth-validator-watcher --config etc/config.dev.yaml 16 | 17 | # Local development 18 | dev-hoodi: 19 | uv pip install -e . 20 | uv run eth-validator-watcher --config etc/config.hoodi.yaml 21 | 22 | # Build docker image 23 | docker: 24 | docker build -t eth-validator-watcher . 25 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Ethereum Validator Watcher 2 | site_description: A monitoring tool for Ethereum validators 3 | site_url: https://github.com/kilnfi/eth-validator-watcher 4 | repo_name: kilnfi/eth-validator-watcher 5 | repo_url: https://github.com/kilnfi/eth-validator-watcher 6 | edit_uri: edit/main/docs/ 7 | 8 | theme: 9 | name: material 10 | palette: 11 | primary: orange 12 | accent: orange 13 | logo: img/Kiln_Logo-Transparent-Dark.svg 14 | favicon: img/Kiln_Logo-Transparent-Dark.svg 15 | features: 16 | - navigation.instant 17 | - navigation.tracking 18 | - navigation.expand 19 | - navigation.indexes 20 | - content.code.copy 21 | 22 | markdown_extensions: 23 | - pymdownx.highlight 24 | - pymdownx.superfences 25 | - pymdownx.inlinehilite 26 | - pymdownx.tabbed 27 | - pymdownx.critic 28 | - pymdownx.tasklist: 29 | custom_checkbox: true 30 | - admonition 31 | - toc: 32 | permalink: true 33 | 34 | plugins: 35 | - search 36 | - mkdocstrings: 37 | handlers: 38 | python: 39 | paths: [eth_validator_watcher] 40 | options: 41 | show_source: false 42 | show_root_heading: true 43 | heading_level: 3 44 | 45 | nav: 46 | - Overview: index.md 47 | - Getting Started: getting-started.md 48 | - API Reference: api-reference.md 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "eth-validator-watcher" 3 | version = "1.0.0-beta.6" 4 | description = "Ethereum Validator Watcher" 5 | authors = [ 6 | {name = "Manu NALEPA", email = "emmanuel.nalepa@kiln.fi"}, 7 | {name = "Sébastien Rannou", email = "mxs@kiln.fi"} 8 | ] 9 | readme = "README.md" 10 | requires-python = ">=3.12" 11 | dependencies = [ 12 | "more-itertools>=9.1.0", 13 | "prometheus-client>=0.17.0", 14 | "pydantic>=2.0", 15 | "requests>=2.31.0", 16 | "typer>=0.9.0", 17 | "slack-sdk>=3.21.3", 18 | "tenacity>=8.2.2", 19 | "pyyaml>=6.0.1", 20 | "pydantic-yaml>=1.2.0", 21 | "pydantic-settings>=2.1.0", 22 | "cachetools>=5.3.3", 23 | "pybind11>=2.12.0", 24 | "vcrpy>=6.0.1", 25 | "pytest-timeout>=2.4.0", 26 | ] 27 | 28 | [project.optional-dependencies] 29 | dev = [ 30 | "mypy>=1.2.0", 31 | "black>=23.3.0", 32 | "pytest>=7.3.1", 33 | "pytest-cov>=4.0.0", 34 | "requests-mock>=1.10.0", 35 | "freezegun>=1.2.2", 36 | "flake8>=7.2.0" 37 | ] 38 | 39 | [build-system] 40 | requires = ["setuptools>=61.0", "pybind11>=2.12.0", "wheel"] 41 | build-backend = "setuptools.build_meta" 42 | 43 | [project.scripts] 44 | eth-validator-watcher = "eth_validator_watcher.entrypoint:app" 45 | 46 | [tool.setuptools] 47 | packages = ["eth_validator_watcher"] 48 | include-package-data = true 49 | 50 | # Custom build script needs to be handled differently with setuptools 51 | [tool.custom] 52 | build-script = "build.py" 53 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | annotated-types==0.7.0 2 | black==25.1.0 3 | cachetools==5.5.2 4 | certifi==2025.1.31 5 | charset-normalizer==3.4.1 6 | click==8.1.8 7 | coverage==7.7.1 8 | -e file:///Users/mxs/Code/kiln/eth-validator-watcher 9 | freezegun==1.5.1 10 | idna==3.10 11 | iniconfig==2.1.0 12 | markdown-it-py==3.0.0 13 | mdurl==0.1.2 14 | more-itertools==10.6.0 15 | multidict==6.2.0 16 | mypy==1.15.0 17 | mypy-extensions==1.0.0 18 | packaging==24.2 19 | pathspec==0.12.1 20 | platformdirs==4.3.7 21 | pluggy==1.5.0 22 | prometheus-client==0.21.1 23 | propcache==0.3.0 24 | pybind11==2.13.6 25 | pydantic==2.10.6 26 | pydantic-core==2.27.2 27 | pydantic-settings==2.8.1 28 | pydantic-yaml==1.4.0 29 | pygments==2.19.1 30 | pytest==8.3.5 31 | pytest-cov==6.0.0 32 | python-dateutil==2.9.0.post0 33 | python-dotenv==1.0.1 34 | pyyaml==6.0.2 35 | requests==2.32.3 36 | requests-mock==1.12.1 37 | rich==13.9.4 38 | ruamel-yaml==0.18.10 39 | shellingham==1.5.4 40 | six==1.17.0 41 | slack-sdk==3.35.0 42 | tenacity==9.0.0 43 | typer==0.15.2 44 | typing-extensions==4.12.2 45 | urllib3==2.3.0 46 | vcrpy==7.0.0 47 | wrapt==1.17.2 48 | yarl==1.18.3 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from setuptools import setup 4 | import sys 5 | import os 6 | 7 | # Import the build function from build.py 8 | sys.path.insert(0, os.path.abspath('.')) 9 | from build import build 10 | 11 | if __name__ == "__main__": 12 | kwargs = {} 13 | build(kwargs) 14 | setup(**kwargs) -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilnfi/eth-validator-watcher/da97e884b4e349e640f74ed779f8ad41c2fcc845/tests/__init__.py -------------------------------------------------------------------------------- /tests/assets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilnfi/eth-validator-watcher/da97e884b4e349e640f74ed779f8ad41c2fcc845/tests/assets/__init__.py -------------------------------------------------------------------------------- /tests/assets/config.empty.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilnfi/eth-validator-watcher/da97e884b4e349e640f74ed779f8ad41c2fcc845/tests/assets/config.empty.yaml -------------------------------------------------------------------------------- /tests/assets/config.null.yaml: -------------------------------------------------------------------------------- 1 | beacon_url: ~ 2 | beacon_timeout_sec: ~ 3 | metrics_port: ~ 4 | network: ~ 5 | watched_keys: ~ 6 | -------------------------------------------------------------------------------- /tests/assets/config.sepolia.yaml: -------------------------------------------------------------------------------- 1 | beacon_url: http://localhost:5052 2 | beacon_timeout_sec: 720 3 | network: sepolia 4 | metrics_port: 8000 5 | 6 | replay_start_at_ts: 1747989420 7 | replay_end_at_ts: 1747989960 8 | 9 | watched_keys: 10 | - public_key: '989fa046d04b41fc95a04dabb7ab8b64e84afaa85c0aa49e1c6878d7b2814094402d62ae42dfbf3ac72e6770ee0926a8' 11 | labels: ["operator:kiln", "vc:prysm-validator-1"] 12 | - public_key: '86edef59ab60ce98ae8d7a02e693970ca1cc6fa4372a59becc7ccca2a95e8f20a419899c8ccbb9c3e848f24178c15123' 13 | labels: ["operator:kiln", "vc:prysm-validator-1"] 14 | - public_key: '86bfb15c8155ec969dbdc6df4e310f32e89b0a9106941deaae52a299cf9a4fa6d7234f210e21ca1ab173025590507bb2' 15 | labels: ["operator:kiln", "vc:prysm-validator-1"] 16 | - public_key: 'b409f87f0632aae9bc081345b17a50a767ba4198f9ac9d352246fb3bebd29ed53c9d6f148c2f318c2eb12846b0aac4cb' 17 | labels: ["operator:kiln", "vc:prysm-validator-1"] 18 | - public_key: 'a5817c74a394b0359a4376ef7e9e8f7dfa6a7829602da225074fb392b715e1fd52c50cae0f128a7006f28b22f233fbf5' 19 | labels: ["operator:kiln", "vc:prysm-validator-1"] 20 | - public_key: 'b09d7c4e74e45aa7fa9f7ffd32e3420e6e4e373217ea824ff0723ec0574d0a5575b6dbca7b98c5ab7b981299e315099e' 21 | labels: ["operator:kiln", "vc:prysm-validator-1"] 22 | - public_key: 'abd7248ae069d3a3a45b0ef4dd5d7d54b62994e578ea20bdd3b7876596673953b94c5b109a6e4b953b517544b915368f' 23 | labels: ["operator:kiln", "vc:prysm-validator-1"] 24 | - public_key: '95d1f944b0c53eb3e9fcd5632713602bbb9195b87a172a370ae2df98504612a55f3968615a39b569ce6a0fe9fb559be7' 25 | labels: ["operator:kiln", "vc:prysm-validator-1"] 26 | - public_key: 'a77f96ae68fe39b3ae3260de804cf348d12c954c3320c07e411b95104da25882b414d282a98bbfbf3dff77442244e887' 27 | labels: ["operator:kiln", "vc:prysm-validator-1"] 28 | - public_key: '8f0f48114501787a622dfb4bf1de666280e3e592101c59f207b1cd7514bbde8a13e95f2b3f09af291b68b9140c1d9137' 29 | labels: ["operator:kiln", "vc:prysm-validator-1"] 30 | - public_key: 'b8876bda1e709ab16e1347a1107852a7898a334a84af978de39920790b4d82eb0739cbfc34da1c7154dd6e9f7674759c' 31 | labels: ["operator:kiln", "vc:prysm-validator-1"] 32 | - public_key: 'af861bb21f84c3e7cab55370839d18ac24c475a9e833607fcd7e65c773ee9d64bea203ee9e8fd9d337f1cd28d5ca0d90' 33 | labels: ["operator:kiln", "vc:prysm-validator-1"] 34 | - public_key: 'b4d5ad2fa79ce408d9b13523764ad5c7c6c7ffe96fdf1988658ef7baf28118b33d48eb9c3e21d1951fd4499f196d2f0a' 35 | labels: ["operator:kiln", "vc:prysm-validator-1"] 36 | - public_key: 'ab7c058199294c02e1edf9b790004f971cb8c41ae7efd25592705970141cdd5318e8eb187959f1ac8bf45c59f1ead0d9' 37 | labels: ["operator:kiln", "vc:prysm-validator-1"] 38 | - public_key: '88ad79a0320a896415e15b827a89969b4590d4dfa269b662bdc8f4633618f67b249f7e35a35884b131772d08025bfc32' 39 | labels: ["operator:kiln", "vc:prysm-validator-1"] 40 | - public_key: 'a9d47cb4c69fde551b2648a2444091502a56a778212ab544ac75cc1bd14d0f043f4e31de47fce9a890ef5428cc28dd41' 41 | labels: ["operator:kiln", "vc:prysm-validator-1"] 42 | - public_key: 'a0ea0827b17130cae727928ad22dca3a844beebee3f11b2e511782f8bbc8773ca9eb351348f7711fa1f5aba0b29190d4' 43 | labels: ["operator:kiln", "vc:prysm-validator-1"] 44 | - public_key: '84a6edac5ac68a7ca837c46d5ada8fab136748b6c3a3b9165dbbc231ec386b15328e4ef7d69a15d4cf354135348a4ee4' 45 | labels: ["operator:kiln", "vc:prysm-validator-1"] 46 | - public_key: '898deb30ede570d391266c81132a78239083aa9e27a9068e26a3bc14ff6468c3f2423484efb2f808b4996c16bfee0932' 47 | labels: ["operator:kiln", "vc:prysm-validator-1"] 48 | - public_key: 'b1632f726d2aea275be4d132e0cda008caf03c91640959b3c62568d87c24adbeb6883a32828bfa99abeca8294cc5e9ce' 49 | labels: ["operator:kiln", "vc:prysm-validator-1"] 50 | - public_key: 'a2040b80ceba0fad581f904f743e620f78172af026a9ad5ecc2f627f0181ab10c6cee238b07d1ba0e459c97bb85f7f48' 51 | labels: ["operator:kiln", "vc:prysm-validator-1"] 52 | - public_key: 'ad7d2e3820e9c9afb8afe3d01b62bf7e05d1d5c3697045562059a4421892e37515ad87251c780f917e3cc72fbd318be5' 53 | labels: ["operator:kiln", "vc:prysm-validator-1"] 54 | - public_key: '8f9aededb605db4e499d3c383b0984b1322007c748dea18dc2f1c73da104a5c0bece6bb41d83abdfac594954801b6b62' 55 | labels: ["operator:kiln", "vc:prysm-validator-1"] 56 | - public_key: '908d762396519ce3c409551b3b5915033cdfe521a586d5c17f49c1d2faa6cb59fa51e1fb74f200487bea87a1d6f37477' 57 | labels: ["operator:kiln", "vc:prysm-validator-1"] 58 | - public_key: '8bb045e7482b7abe670d72eb2f7afe4207b5a3d488364ff7bb4266f8784ea41893553a4bf7d01e78c99ed9008e2c13bb' 59 | labels: ["operator:kiln", "vc:prysm-validator-1"] 60 | - public_key: '95791fb6b08443445b8757906f3a2b1a8414a9016b5f8059c577752b701d6dc1fe9b784bac1fa57a1446b7adfd11c868' 61 | labels: ["operator:kiln", "vc:prysm-validator-1"] 62 | - public_key: 'ac7983d50ec447b65e62ed38054d8e8242c31b40030f630098ce0a4e93536da9179c3f3ae0b34a0b02aad427a97ee60d' 63 | labels: ["operator:kiln", "vc:prysm-validator-1"] 64 | - public_key: 'b2235bdf60dde5d0d78c72cb69e6e09153b0154efdbab97e1bc91f18d3cec4f660a80311fe6a1acd419a448ab65b18f1' 65 | labels: ["operator:kiln", "vc:prysm-validator-1"] 66 | - public_key: 'a7e8775e04214e3b9898ffb9625dc8bcd1b683e333acdceddb8ca6db241df08a7b80e9d476a711b8b7d66aefca81e9cd' 67 | labels: ["operator:kiln", "vc:prysm-validator-1"] 68 | - public_key: '847b58626f306ef2d785e3fe1b6515f98d9f72037eea0604d92e891a0219142fec485323bec4e93a4ee132af61026b80' 69 | labels: ["operator:kiln", "vc:prysm-validator-1"] 70 | - public_key: 'b576c49c2a7b7c3445bbf9ba8eac10e685cc3760d6819de43b7d1e20769772bcab9f557df96f28fd24409ac8c84d05c4' 71 | labels: ["operator:kiln", "vc:prysm-validator-1"] 72 | - public_key: 'a0617db822d559764a23c4361e849534d4b411e2cf9e1c4132c1104085175aa5f2ce475a6d1d5cb178056945ca782182' 73 | labels: ["operator:kiln", "vc:prysm-validator-1"] 74 | - public_key: '88b49b1130f9df26407ff3f6ac10539a6a67b6ddcc73eaf27fe2a18fb69aa2aff0581a5b0eef96b9ddd3cb761bdbbf51' 75 | labels: ["operator:kiln", "vc:prysm-validator-1"] 76 | - public_key: '838d5eee51f5d65c9ed1632d042bb7f88161f3789e6bb461318c5400eaf6728e7ba0f92c18e1a994aa4743145c96164b' 77 | labels: ["operator:kiln", "vc:prysm-validator-1"] 78 | - public_key: 'a07826925f401a7b4222d869bb8794b5714ef2fc66fba2b1170fcac98bed4ba85d976cf9ee268be8a349ae99e17ac075' 79 | labels: ["operator:kiln", "vc:prysm-validator-1"] 80 | - public_key: '83bbd31e799ac14686085868e8ea7587c7c7969c7015bfe45fd8e3a3847ad5338005f9cdf58396b2ea833c4af98bd9ca' 81 | labels: ["operator:kiln", "vc:prysm-validator-1"] 82 | - public_key: '8effe3fb27c9f76bbd78687b743b52e6f3330eddc81bc9006ca81fd640f149d73630af578694f4530833c2151522dcc1' 83 | labels: ["operator:kiln", "vc:prysm-validator-1"] 84 | - public_key: '8e2a281e944a28673fb8b47aaa288375cefd3a6be20e453131d85363ecc4fd5b250e7f9d7ca1e53408c54943041945a2' 85 | labels: ["operator:kiln", "vc:prysm-validator-1"] 86 | - public_key: 'a26dd9b28564c3d95679aca03e3432ac26e287f80e870714c5946b05538b3cb43bba7b85c16bceb5430e81b7a04c1b1d' 87 | labels: ["operator:kiln", "vc:prysm-validator-1"] 88 | - public_key: '8d797819318cdf7b26405d1a327d80d4c289e56f830b28d4e303bcb019aeb0b3d69bfed58adcde8a2445dd5281b86af1' 89 | labels: ["operator:kiln", "vc:prysm-validator-1"] 90 | - public_key: '8853eff72fa4c7b4eda77e448e12bc8ee75f5cb0f35b721c7ee8184cf030a11e3e0278a4e76b326416fd645a9645d901' 91 | labels: ["operator:kiln", "vc:prysm-validator-1"] 92 | - public_key: 'a2ab566033062b6481eb7e4bbc64ed022407f56aa8dddc1aade76aa54a30ce3256052ce99218b66e6265f70837137a10' 93 | labels: ["operator:kiln", "vc:prysm-validator-1"] 94 | - public_key: '8fed283861ce42b3151d60887d0d3d2ff69869c051aed304af0f1db3f57dabf32f2b6994d9f0f11478eefbbb1daf9a8a' 95 | labels: ["operator:kiln", "vc:prysm-validator-1"] 96 | - public_key: 'a8b742cb7f497adfb99bdc6bcaf7f4bdadded2a6d5958680406b3b00545e1812d78e03e20be42b471b1daba84587d574' 97 | labels: ["operator:kiln", "vc:prysm-validator-1"] 98 | - public_key: '96b1c82b85cdb8a7026fd3431bea9cd008f0261ee7f4179f4e69a399872837ab836a14e2dd45f5448d54800a4ae7c7f2' 99 | labels: ["operator:kiln", "vc:prysm-validator-1"] 100 | - public_key: '86cef0506d35ac8afa7509561aa90bbc89663f7f880a86b0aa838464a33a36f27808cd8b68fa6f729e6eede4ab0583da' 101 | labels: ["operator:kiln", "vc:prysm-validator-1"] 102 | - public_key: 'a57bacada151d6521c6f40371e80cc8e44bb76389dfa7da5deba7675bb9a253c59a901df26c6f1069205b37f18048b1c' 103 | labels: ["operator:kiln", "vc:prysm-validator-1"] 104 | - public_key: 'a778da56ddfe4a383816b43b027464d7a28689fc4a6b35b36883d3f36d9c41f0177bdbfc8f258afe8da90f02d3b64fea' 105 | labels: ["operator:kiln", "vc:prysm-validator-1"] 106 | - public_key: 'a4e2df74c8e7257e3df1e4f6a9ad4141c8299f43f02bcc53bfeeaa1698faecf81a4ad2be7f5ddbd1be657c87110ea34c' 107 | labels: ["operator:kiln", "vc:prysm-validator-1"] 108 | - public_key: 'a4a7dd1aaf815c271a639b044efffd5bf575a22cf895adf5813d5ab1b63a60e5bfb08d275e890ec695979a0256e4cfc7' 109 | labels: ["operator:kiln", "vc:prysm-validator-1"] 110 | - public_key: 'a8f2572a2cc2ecba151a3d5f4040a70172067ddadd8c12ba9d60f993eb0eab6698cb35932949c9a42e45b36a822af40e' 111 | labels: ["operator:kiln", "vc:teku-validator-1"] 112 | - public_key: '993726e0b1c2277b97b83c80192e14b67977bf21b6ebcde2bda30261aa1897251cd2e277cfcb6193517f1eb156d2fe86' 113 | labels: ["operator:kiln", "vc:teku-validator-1"] 114 | - public_key: 'b6cacc458ca5a0f04836c5640d34c70cab34cb5c87df28579a0d5e2c72edbc092814bdbe902747ebe3ce36808d8f4dac' 115 | labels: ["operator:kiln", "vc:teku-validator-1"] 116 | - public_key: 'ab6b47627cf76d9552c723818db5ebee7734542436b50ffe15b3a96e8e7a6b54f9a0965de78405e16e309193f147108d' 117 | labels: ["operator:kiln", "vc:teku-validator-1"] 118 | - public_key: '86b3a4ea9b1fde00cce79d5ae480353d60cb6ddce363c535bbbc3e41a4b8e39fcf2978eb430091ae1b10420d43193971' 119 | labels: ["operator:kiln", "vc:teku-validator-1"] 120 | - public_key: '8f90e72a54e6894d511061957162e753010812346afd4d90cfedb678b99ba1aacf2b6bd0e49b4b0e684da8082a048619' 121 | labels: ["operator:kiln", "vc:teku-validator-1"] 122 | - public_key: 'b1cca4f417063a861f6c5b4bbe2b129bc72003de58bab895325283ff5f1045af808da9048fa72217863e3de5ac87286d' 123 | labels: ["operator:kiln", "vc:teku-validator-1"] 124 | - public_key: '8c6fc89428c74f0c025e980c5a1e576deadf8685f57136e50600175fa2d19389c853d532bb45a3e22b4a879fab1fcb0d' 125 | labels: ["operator:kiln", "vc:teku-validator-1"] 126 | - public_key: '8c7ccbea47f3fb6c15863c84c99a9094a00f2b5836200eeb73dbf84fc8e7856369dc7ab09f9d51ae42909fa94c895afc' 127 | labels: ["operator:kiln", "vc:teku-validator-1"] 128 | - public_key: '8163eea18eacc062e71bb9f7406c58ebe1ce42a8b93656077dd781c2772e37775fe20e8d5b980dd52fdad98b72f10b71' 129 | labels: ["operator:kiln", "vc:teku-validator-1"] 130 | - public_key: 'b4a1d185c770ed41021ab0497a2ecf724fbd046784418b8a4af8d654dd9b10c2f3333e6f4f9e6ce385916546a2cb6a8e' 131 | labels: ["operator:kiln", "vc:teku-validator-1"] 132 | - public_key: 'b58396bce7d32ba6c70adbd37156d859e153c1932d2b0c7c874a1182ba831439e80d6fc6d7d88a870e193f515aef2264' 133 | labels: ["operator:kiln", "vc:teku-validator-1"] 134 | - public_key: '93e4d7740847caeeaca68e0b8f9a81b9475435108861506e3d3ccd3d716e05ced294ac30743eb9f45496acd6438b255d' 135 | labels: ["operator:kiln", "vc:teku-validator-1"] 136 | - public_key: '8a9f7e8d45f11c4bfb0921c6008f3c79ff923452bcfa7769beb3222f1f37dcb861be979e6eae187f06cf26af05e8ee5b' 137 | labels: ["operator:kiln", "vc:teku-validator-1"] 138 | - public_key: '966256693e9cd01d67855d9a834f39a8e7628f531e136b5113b7cdb91e17b554fcbef2611929b74710606585b1df59b5' 139 | labels: ["operator:kiln", "vc:teku-validator-1"] 140 | - public_key: '8910f41db6952c25dfbf6b6b5ba252a2d999c51537d35a0d86b7688bb54dcb6f11eb755a5dce366113dfb2f6b56802b7' 141 | labels: ["operator:kiln", "vc:teku-validator-1"] 142 | - public_key: '8100b48ac2785477a123a7967bfcea8bacef59391680a411692880098a08771ff9786bd3b8dfb034cae00d5a7665621c' 143 | labels: ["operator:kiln", "vc:teku-validator-1"] 144 | - public_key: 'aeeedb3c73a9eadef14396a474ca83ca9e3885fd5f2c1018652360481d0be49524de22fc1ea18bb7abca66df5dc7d309' 145 | labels: ["operator:kiln", "vc:teku-validator-1"] 146 | - public_key: 'a8e03a26e88e4ed03751ccf6eeed6215becbf4c2d58be27361f61d1cc4ac9b692fc6ecdb839f9b3c17f54fc2f2f4756e' 147 | labels: ["operator:kiln", "vc:teku-validator-1"] 148 | - public_key: '8600e2031c9113ad2a75c19872b5efef85765b524f74de98baf4efe4a75c6be563e9e19622388fbe9afe58aa6017b930' 149 | labels: ["operator:kiln", "vc:teku-validator-1"] 150 | - public_key: '982d84a38d17b96d5729456c60f76efc9aaa0fccf66d99d5949b1f09a8867dee10ab70fb1b317fa4a794173d9ca95b16' 151 | labels: ["operator:kiln", "vc:teku-validator-1"] 152 | - public_key: 'b8aba8f15ea91d23e660736ac87f3641f5233911ca6ca65805ad6890436ebc561555429407ba6b1b39ccf3a917a03dd8' 153 | labels: ["operator:kiln", "vc:teku-validator-1"] 154 | - public_key: '853ee4db23d9ee501a651fbc900ba81fbf9397d914f1a7437afc247e7a666054d0197f02c1d12a76c43ee5c82784009f' 155 | labels: ["operator:kiln", "vc:teku-validator-1"] 156 | - public_key: 'b76f598fd5c28d742bc1a81af84f35f1284d62239989f1025e9eba9bece2d746a52f246f9bb6bcfde888b9f7b67fc4f6' 157 | labels: ["operator:kiln", "vc:teku-validator-1"] 158 | - public_key: 'afe3b6323ee16b10849404f2cb8eecc06ecef0c5ca05185f6640093948b36512d9896e7558dea0943d7e2eee8f65fdb1' 159 | labels: ["operator:kiln", "vc:teku-validator-1"] 160 | - public_key: '8eebee05702bf1574b12597b72a86d5badef064879fa9d1b9aff5ab75e5c71d81d8bc404f2614085855d6ed87f581238' 161 | labels: ["operator:kiln", "vc:teku-validator-1"] 162 | - public_key: 'a61cb5b148cb7ff34775dead8efa7d54d7141182356bf614070dfaa710ebf07a4dfb684dad151db60c0f8261c30a4f40' 163 | labels: ["operator:kiln", "vc:teku-validator-1"] 164 | - public_key: 'b7a2c83971c4e4132f3fcaf3c4374872de67ea5d89814492309cf924520a23787401f9621681fcf526154e80849a7e72' 165 | labels: ["operator:kiln", "vc:teku-validator-1"] 166 | - public_key: '8e70e4867d2731901d603928d72bbeb34b2e0339a4f5cf06e7a771640717421b4ea039c61dde951582a28c2ff152ff70' 167 | labels: ["operator:kiln", "vc:teku-validator-1"] 168 | - public_key: '8085c60b6b12ac8a5be8a7e24977663125c34827842aa3b2730854ab199dd0d2eaa93084c9599f0939be8db6758b198b' 169 | labels: ["operator:kiln", "vc:teku-validator-1"] 170 | - public_key: 'a57d5de556853484b1d88808d2529450238bc467376ded84cfd7b4a1ba258f6d43b5958220f962c57b033abcef1d5158' 171 | labels: ["operator:kiln", "vc:teku-validator-1"] 172 | - public_key: '903f569a8de771406b9fd36384f1fea20d5d79374b8d9af24b4814f96c44739193662aa47be857543fa101aa70ab205d' 173 | labels: ["operator:kiln", "vc:teku-validator-1"] 174 | - public_key: '85745bd84c92ddfc55df11fe134cf70e3c340aa1c7cdd6188a03308cf3a840f4f19629f9730b2e6426424989ff03000d' 175 | labels: ["operator:kiln", "vc:teku-validator-1"] 176 | - public_key: '84d3e2a06e16ced26094b356a16a4fb6aad50ad9ab23ef804a5852a33ef0bff76f3c5fbf7beb062376c2e669cb598679' 177 | labels: ["operator:kiln", "vc:teku-validator-1"] 178 | - public_key: 'af7cc29753903e70fcca8333fb7fadf4d7f6b8c20716bbb831815bbfab819b48c1e9b19148cf62392ad95c67c7bb0229' 179 | labels: ["operator:kiln", "vc:teku-validator-1"] 180 | - public_key: 'aebb24b64beafc6460ccd8445cee4a855b7656e98ba2cd11bd47c6303a243edc2cde1ddb09a9487b21db850479572b37' 181 | labels: ["operator:kiln", "vc:teku-validator-1"] 182 | - public_key: 'b0d4231814e40e53ab4eed8333d418a6e2e4bd3910148b610dec5f91961df1ad63f4661d533137a503d809ea1ad576fa' 183 | labels: ["operator:kiln", "vc:teku-validator-1"] 184 | - public_key: '81db8bf89aa98475a15a887c3c216690428609d09c22213b5d91cb34c7831b75ef95e219c5497c81cad1ce9da18ec41c' 185 | labels: ["operator:kiln", "vc:teku-validator-1"] 186 | - public_key: '9466afdb35d113733c0bc10b2e08ceba1132881c126524417602fc5a3fa4a626f6474b5f3f6c6dff49d74b9d8e91051b' 187 | labels: ["operator:kiln", "vc:teku-validator-1"] 188 | - public_key: '86ca8ed7c475d33455fae4242b05b1b3576e6ec05ac512ca7d3f9c8d44376e909c734c25cd0e33f0f6b4857d40452024' 189 | labels: ["operator:kiln", "vc:teku-validator-1"] 190 | - public_key: 'b01a30d439def99e676c097e5f4b2aa249aa4d184eaace81819a698cb37d33f5a24089339916ee0acb539f0e62936d83' 191 | labels: ["operator:kiln", "vc:teku-validator-1"] 192 | - public_key: '8d77e65ba6250fe18c54ce70d0ba4571a7d3e68a8b169055cd208e4434b35a4297e154775c73e7dfba511faadb2598c5' 193 | labels: ["operator:kiln", "vc:teku-validator-1"] 194 | - public_key: 'b9ee3b7b95db0122edd90b641de3c07fbf63a4f70fee5f72051cbe25c92d88444314d0489a5ecdb1805f4f149f462ee6' 195 | labels: ["operator:kiln", "vc:teku-validator-1"] 196 | - public_key: '87970b6946fc6f64010ce3e78de71a365814266707b23f871890dbdc6c5d1ad47dd3baa94da9eefc87523798cef84ff2' 197 | labels: ["operator:kiln", "vc:teku-validator-1"] 198 | - public_key: 'a0230bdf83cd469c7248074bec535eba8280cfde587d7c63d307149e9626bc7642b4bacc9beff2d8e8f6ea398dc0ade7' 199 | labels: ["operator:kiln", "vc:teku-validator-1"] 200 | - public_key: 'a3e1fe11f38d3954a7f48c8b68ff956ea0b6f8a3e603fd258c9406ec2b685ff48241db5257179ea020a83c31dc963854' 201 | labels: ["operator:kiln", "vc:teku-validator-1"] 202 | - public_key: '8ebfbcaccddd2489c4a29a374a2babc26987c3312607eadb2c4b0a53a17de97107c54eab34def09144b3098c082c286b' 203 | labels: ["operator:kiln", "vc:teku-validator-1"] 204 | - public_key: '95c0a30943ef34ef0a644439d857446e1c1736e18360f3f41803b0ca118e79af3fb9c608ec440a8de0f79d2c245b583c' 205 | labels: ["operator:kiln", "vc:teku-validator-1"] 206 | - public_key: '906cde18b34f777027d0c64b16c94c9d8f94250449d353e94972d42c94dd4d915aa1b6c73a581da2986e09f336af9673' 207 | labels: ["operator:kiln", "vc:teku-validator-1"] 208 | - public_key: 'a9ee291de5997232c68c9f6c3b568b05f46bfedfa18eb3776699d98cc7c6320003b7d862564d07fd28fc3691d1d28b21' 209 | labels: ["operator:kiln", "vc:teku-validator-1"] 210 | -------------------------------------------------------------------------------- /tests/assets/config.sepolia_replay_2_slots.yaml: -------------------------------------------------------------------------------- 1 | beacon_url: http://localhost:5052/ 2 | beacon_timeout_sec: 90 3 | network: sepolia 4 | metrics_port: 8000 5 | 6 | replay_start_at_ts: 1747989420 7 | replay_end_at_ts: 1747989450 8 | 9 | watched_keys: 10 | - public_key: '989fa046d04b41fc95a04dabb7ab8b64e84afaa85c0aa49e1c6878d7b2814094402d62ae42dfbf3ac72e6770ee0926a8' 11 | labels: ["operator:kiln", "vc:prysm-validator-1"] 12 | - public_key: '86edef59ab60ce98ae8d7a02e693970ca1cc6fa4372a59becc7ccca2a95e8f20a419899c8ccbb9c3e848f24178c15123' 13 | labels: ["operator:kiln", "vc:prysm-validator-1"] 14 | - public_key: '86bfb15c8155ec969dbdc6df4e310f32e89b0a9106941deaae52a299cf9a4fa6d7234f210e21ca1ab173025590507bb2' 15 | labels: ["operator:kiln", "vc:prysm-validator-1"] 16 | - public_key: 'b409f87f0632aae9bc081345b17a50a767ba4198f9ac9d352246fb3bebd29ed53c9d6f148c2f318c2eb12846b0aac4cb' 17 | labels: ["operator:kiln", "vc:prysm-validator-1"] 18 | - public_key: 'a5817c74a394b0359a4376ef7e9e8f7dfa6a7829602da225074fb392b715e1fd52c50cae0f128a7006f28b22f233fbf5' 19 | labels: ["operator:kiln", "vc:prysm-validator-1"] 20 | - public_key: 'b09d7c4e74e45aa7fa9f7ffd32e3420e6e4e373217ea824ff0723ec0574d0a5575b6dbca7b98c5ab7b981299e315099e' 21 | labels: ["operator:kiln", "vc:prysm-validator-1"] 22 | - public_key: 'abd7248ae069d3a3a45b0ef4dd5d7d54b62994e578ea20bdd3b7876596673953b94c5b109a6e4b953b517544b915368f' 23 | labels: ["operator:kiln", "vc:prysm-validator-1"] 24 | - public_key: '95d1f944b0c53eb3e9fcd5632713602bbb9195b87a172a370ae2df98504612a55f3968615a39b569ce6a0fe9fb559be7' 25 | labels: ["operator:kiln", "vc:prysm-validator-1"] 26 | - public_key: 'a77f96ae68fe39b3ae3260de804cf348d12c954c3320c07e411b95104da25882b414d282a98bbfbf3dff77442244e887' 27 | labels: ["operator:kiln", "vc:prysm-validator-1"] 28 | - public_key: '8f0f48114501787a622dfb4bf1de666280e3e592101c59f207b1cd7514bbde8a13e95f2b3f09af291b68b9140c1d9137' 29 | labels: ["operator:kiln", "vc:prysm-validator-1"] 30 | - public_key: 'b8876bda1e709ab16e1347a1107852a7898a334a84af978de39920790b4d82eb0739cbfc34da1c7154dd6e9f7674759c' 31 | labels: ["operator:kiln", "vc:prysm-validator-1"] 32 | - public_key: 'af861bb21f84c3e7cab55370839d18ac24c475a9e833607fcd7e65c773ee9d64bea203ee9e8fd9d337f1cd28d5ca0d90' 33 | labels: ["operator:kiln", "vc:prysm-validator-1"] 34 | - public_key: 'b4d5ad2fa79ce408d9b13523764ad5c7c6c7ffe96fdf1988658ef7baf28118b33d48eb9c3e21d1951fd4499f196d2f0a' 35 | labels: ["operator:kiln", "vc:prysm-validator-1"] 36 | - public_key: 'ab7c058199294c02e1edf9b790004f971cb8c41ae7efd25592705970141cdd5318e8eb187959f1ac8bf45c59f1ead0d9' 37 | labels: ["operator:kiln", "vc:prysm-validator-1"] 38 | - public_key: '88ad79a0320a896415e15b827a89969b4590d4dfa269b662bdc8f4633618f67b249f7e35a35884b131772d08025bfc32' 39 | labels: ["operator:kiln", "vc:prysm-validator-1"] 40 | - public_key: 'a9d47cb4c69fde551b2648a2444091502a56a778212ab544ac75cc1bd14d0f043f4e31de47fce9a890ef5428cc28dd41' 41 | labels: ["operator:kiln", "vc:prysm-validator-1"] 42 | - public_key: 'a0ea0827b17130cae727928ad22dca3a844beebee3f11b2e511782f8bbc8773ca9eb351348f7711fa1f5aba0b29190d4' 43 | labels: ["operator:kiln", "vc:prysm-validator-1"] 44 | - public_key: '84a6edac5ac68a7ca837c46d5ada8fab136748b6c3a3b9165dbbc231ec386b15328e4ef7d69a15d4cf354135348a4ee4' 45 | labels: ["operator:kiln", "vc:prysm-validator-1"] 46 | - public_key: '898deb30ede570d391266c81132a78239083aa9e27a9068e26a3bc14ff6468c3f2423484efb2f808b4996c16bfee0932' 47 | labels: ["operator:kiln", "vc:prysm-validator-1"] 48 | - public_key: 'b1632f726d2aea275be4d132e0cda008caf03c91640959b3c62568d87c24adbeb6883a32828bfa99abeca8294cc5e9ce' 49 | labels: ["operator:kiln", "vc:prysm-validator-1"] 50 | - public_key: 'a2040b80ceba0fad581f904f743e620f78172af026a9ad5ecc2f627f0181ab10c6cee238b07d1ba0e459c97bb85f7f48' 51 | labels: ["operator:kiln", "vc:prysm-validator-1"] 52 | - public_key: 'ad7d2e3820e9c9afb8afe3d01b62bf7e05d1d5c3697045562059a4421892e37515ad87251c780f917e3cc72fbd318be5' 53 | labels: ["operator:kiln", "vc:prysm-validator-1"] 54 | - public_key: '8f9aededb605db4e499d3c383b0984b1322007c748dea18dc2f1c73da104a5c0bece6bb41d83abdfac594954801b6b62' 55 | labels: ["operator:kiln", "vc:prysm-validator-1"] 56 | - public_key: '908d762396519ce3c409551b3b5915033cdfe521a586d5c17f49c1d2faa6cb59fa51e1fb74f200487bea87a1d6f37477' 57 | labels: ["operator:kiln", "vc:prysm-validator-1"] 58 | - public_key: '8bb045e7482b7abe670d72eb2f7afe4207b5a3d488364ff7bb4266f8784ea41893553a4bf7d01e78c99ed9008e2c13bb' 59 | labels: ["operator:kiln", "vc:prysm-validator-1"] 60 | - public_key: '95791fb6b08443445b8757906f3a2b1a8414a9016b5f8059c577752b701d6dc1fe9b784bac1fa57a1446b7adfd11c868' 61 | labels: ["operator:kiln", "vc:prysm-validator-1"] 62 | - public_key: 'ac7983d50ec447b65e62ed38054d8e8242c31b40030f630098ce0a4e93536da9179c3f3ae0b34a0b02aad427a97ee60d' 63 | labels: ["operator:kiln", "vc:prysm-validator-1"] 64 | - public_key: 'b2235bdf60dde5d0d78c72cb69e6e09153b0154efdbab97e1bc91f18d3cec4f660a80311fe6a1acd419a448ab65b18f1' 65 | labels: ["operator:kiln", "vc:prysm-validator-1"] 66 | - public_key: 'a7e8775e04214e3b9898ffb9625dc8bcd1b683e333acdceddb8ca6db241df08a7b80e9d476a711b8b7d66aefca81e9cd' 67 | labels: ["operator:kiln", "vc:prysm-validator-1"] 68 | - public_key: '847b58626f306ef2d785e3fe1b6515f98d9f72037eea0604d92e891a0219142fec485323bec4e93a4ee132af61026b80' 69 | labels: ["operator:kiln", "vc:prysm-validator-1"] 70 | - public_key: 'b576c49c2a7b7c3445bbf9ba8eac10e685cc3760d6819de43b7d1e20769772bcab9f557df96f28fd24409ac8c84d05c4' 71 | labels: ["operator:kiln", "vc:prysm-validator-1"] 72 | - public_key: 'a0617db822d559764a23c4361e849534d4b411e2cf9e1c4132c1104085175aa5f2ce475a6d1d5cb178056945ca782182' 73 | labels: ["operator:kiln", "vc:prysm-validator-1"] 74 | - public_key: '88b49b1130f9df26407ff3f6ac10539a6a67b6ddcc73eaf27fe2a18fb69aa2aff0581a5b0eef96b9ddd3cb761bdbbf51' 75 | labels: ["operator:kiln", "vc:prysm-validator-1"] 76 | - public_key: '838d5eee51f5d65c9ed1632d042bb7f88161f3789e6bb461318c5400eaf6728e7ba0f92c18e1a994aa4743145c96164b' 77 | labels: ["operator:kiln", "vc:prysm-validator-1"] 78 | - public_key: 'a07826925f401a7b4222d869bb8794b5714ef2fc66fba2b1170fcac98bed4ba85d976cf9ee268be8a349ae99e17ac075' 79 | labels: ["operator:kiln", "vc:prysm-validator-1"] 80 | - public_key: '83bbd31e799ac14686085868e8ea7587c7c7969c7015bfe45fd8e3a3847ad5338005f9cdf58396b2ea833c4af98bd9ca' 81 | labels: ["operator:kiln", "vc:prysm-validator-1"] 82 | - public_key: '8effe3fb27c9f76bbd78687b743b52e6f3330eddc81bc9006ca81fd640f149d73630af578694f4530833c2151522dcc1' 83 | labels: ["operator:kiln", "vc:prysm-validator-1"] 84 | - public_key: '8e2a281e944a28673fb8b47aaa288375cefd3a6be20e453131d85363ecc4fd5b250e7f9d7ca1e53408c54943041945a2' 85 | labels: ["operator:kiln", "vc:prysm-validator-1"] 86 | - public_key: 'a26dd9b28564c3d95679aca03e3432ac26e287f80e870714c5946b05538b3cb43bba7b85c16bceb5430e81b7a04c1b1d' 87 | labels: ["operator:kiln", "vc:prysm-validator-1"] 88 | - public_key: '8d797819318cdf7b26405d1a327d80d4c289e56f830b28d4e303bcb019aeb0b3d69bfed58adcde8a2445dd5281b86af1' 89 | labels: ["operator:kiln", "vc:prysm-validator-1"] 90 | - public_key: '8853eff72fa4c7b4eda77e448e12bc8ee75f5cb0f35b721c7ee8184cf030a11e3e0278a4e76b326416fd645a9645d901' 91 | labels: ["operator:kiln", "vc:prysm-validator-1"] 92 | - public_key: 'a2ab566033062b6481eb7e4bbc64ed022407f56aa8dddc1aade76aa54a30ce3256052ce99218b66e6265f70837137a10' 93 | labels: ["operator:kiln", "vc:prysm-validator-1"] 94 | - public_key: '8fed283861ce42b3151d60887d0d3d2ff69869c051aed304af0f1db3f57dabf32f2b6994d9f0f11478eefbbb1daf9a8a' 95 | labels: ["operator:kiln", "vc:prysm-validator-1"] 96 | - public_key: 'a8b742cb7f497adfb99bdc6bcaf7f4bdadded2a6d5958680406b3b00545e1812d78e03e20be42b471b1daba84587d574' 97 | labels: ["operator:kiln", "vc:prysm-validator-1"] 98 | - public_key: '96b1c82b85cdb8a7026fd3431bea9cd008f0261ee7f4179f4e69a399872837ab836a14e2dd45f5448d54800a4ae7c7f2' 99 | labels: ["operator:kiln", "vc:prysm-validator-1"] 100 | - public_key: '86cef0506d35ac8afa7509561aa90bbc89663f7f880a86b0aa838464a33a36f27808cd8b68fa6f729e6eede4ab0583da' 101 | labels: ["operator:kiln", "vc:prysm-validator-1"] 102 | - public_key: 'a57bacada151d6521c6f40371e80cc8e44bb76389dfa7da5deba7675bb9a253c59a901df26c6f1069205b37f18048b1c' 103 | labels: ["operator:kiln", "vc:prysm-validator-1"] 104 | - public_key: 'a778da56ddfe4a383816b43b027464d7a28689fc4a6b35b36883d3f36d9c41f0177bdbfc8f258afe8da90f02d3b64fea' 105 | labels: ["operator:kiln", "vc:prysm-validator-1"] 106 | - public_key: 'a4e2df74c8e7257e3df1e4f6a9ad4141c8299f43f02bcc53bfeeaa1698faecf81a4ad2be7f5ddbd1be657c87110ea34c' 107 | labels: ["operator:kiln", "vc:prysm-validator-1"] 108 | - public_key: 'a4a7dd1aaf815c271a639b044efffd5bf575a22cf895adf5813d5ab1b63a60e5bfb08d275e890ec695979a0256e4cfc7' 109 | labels: ["operator:kiln", "vc:prysm-validator-1"] 110 | - public_key: 'a8f2572a2cc2ecba151a3d5f4040a70172067ddadd8c12ba9d60f993eb0eab6698cb35932949c9a42e45b36a822af40e' 111 | labels: ["operator:kiln", "vc:teku-validator-1"] 112 | - public_key: '993726e0b1c2277b97b83c80192e14b67977bf21b6ebcde2bda30261aa1897251cd2e277cfcb6193517f1eb156d2fe86' 113 | labels: ["operator:kiln", "vc:teku-validator-1"] 114 | - public_key: 'b6cacc458ca5a0f04836c5640d34c70cab34cb5c87df28579a0d5e2c72edbc092814bdbe902747ebe3ce36808d8f4dac' 115 | labels: ["operator:kiln", "vc:teku-validator-1"] 116 | - public_key: 'ab6b47627cf76d9552c723818db5ebee7734542436b50ffe15b3a96e8e7a6b54f9a0965de78405e16e309193f147108d' 117 | labels: ["operator:kiln", "vc:teku-validator-1"] 118 | - public_key: '86b3a4ea9b1fde00cce79d5ae480353d60cb6ddce363c535bbbc3e41a4b8e39fcf2978eb430091ae1b10420d43193971' 119 | labels: ["operator:kiln", "vc:teku-validator-1"] 120 | - public_key: '8f90e72a54e6894d511061957162e753010812346afd4d90cfedb678b99ba1aacf2b6bd0e49b4b0e684da8082a048619' 121 | labels: ["operator:kiln", "vc:teku-validator-1"] 122 | - public_key: 'b1cca4f417063a861f6c5b4bbe2b129bc72003de58bab895325283ff5f1045af808da9048fa72217863e3de5ac87286d' 123 | labels: ["operator:kiln", "vc:teku-validator-1"] 124 | - public_key: '8c6fc89428c74f0c025e980c5a1e576deadf8685f57136e50600175fa2d19389c853d532bb45a3e22b4a879fab1fcb0d' 125 | labels: ["operator:kiln", "vc:teku-validator-1"] 126 | - public_key: '8c7ccbea47f3fb6c15863c84c99a9094a00f2b5836200eeb73dbf84fc8e7856369dc7ab09f9d51ae42909fa94c895afc' 127 | labels: ["operator:kiln", "vc:teku-validator-1"] 128 | - public_key: '8163eea18eacc062e71bb9f7406c58ebe1ce42a8b93656077dd781c2772e37775fe20e8d5b980dd52fdad98b72f10b71' 129 | labels: ["operator:kiln", "vc:teku-validator-1"] 130 | - public_key: 'b4a1d185c770ed41021ab0497a2ecf724fbd046784418b8a4af8d654dd9b10c2f3333e6f4f9e6ce385916546a2cb6a8e' 131 | labels: ["operator:kiln", "vc:teku-validator-1"] 132 | - public_key: 'b58396bce7d32ba6c70adbd37156d859e153c1932d2b0c7c874a1182ba831439e80d6fc6d7d88a870e193f515aef2264' 133 | labels: ["operator:kiln", "vc:teku-validator-1"] 134 | - public_key: '93e4d7740847caeeaca68e0b8f9a81b9475435108861506e3d3ccd3d716e05ced294ac30743eb9f45496acd6438b255d' 135 | labels: ["operator:kiln", "vc:teku-validator-1"] 136 | - public_key: '8a9f7e8d45f11c4bfb0921c6008f3c79ff923452bcfa7769beb3222f1f37dcb861be979e6eae187f06cf26af05e8ee5b' 137 | labels: ["operator:kiln", "vc:teku-validator-1"] 138 | - public_key: '966256693e9cd01d67855d9a834f39a8e7628f531e136b5113b7cdb91e17b554fcbef2611929b74710606585b1df59b5' 139 | labels: ["operator:kiln", "vc:teku-validator-1"] 140 | - public_key: '8910f41db6952c25dfbf6b6b5ba252a2d999c51537d35a0d86b7688bb54dcb6f11eb755a5dce366113dfb2f6b56802b7' 141 | labels: ["operator:kiln", "vc:teku-validator-1"] 142 | - public_key: '8100b48ac2785477a123a7967bfcea8bacef59391680a411692880098a08771ff9786bd3b8dfb034cae00d5a7665621c' 143 | labels: ["operator:kiln", "vc:teku-validator-1"] 144 | - public_key: 'aeeedb3c73a9eadef14396a474ca83ca9e3885fd5f2c1018652360481d0be49524de22fc1ea18bb7abca66df5dc7d309' 145 | labels: ["operator:kiln", "vc:teku-validator-1"] 146 | - public_key: 'a8e03a26e88e4ed03751ccf6eeed6215becbf4c2d58be27361f61d1cc4ac9b692fc6ecdb839f9b3c17f54fc2f2f4756e' 147 | labels: ["operator:kiln", "vc:teku-validator-1"] 148 | - public_key: '8600e2031c9113ad2a75c19872b5efef85765b524f74de98baf4efe4a75c6be563e9e19622388fbe9afe58aa6017b930' 149 | labels: ["operator:kiln", "vc:teku-validator-1"] 150 | - public_key: '982d84a38d17b96d5729456c60f76efc9aaa0fccf66d99d5949b1f09a8867dee10ab70fb1b317fa4a794173d9ca95b16' 151 | labels: ["operator:kiln", "vc:teku-validator-1"] 152 | - public_key: 'b8aba8f15ea91d23e660736ac87f3641f5233911ca6ca65805ad6890436ebc561555429407ba6b1b39ccf3a917a03dd8' 153 | labels: ["operator:kiln", "vc:teku-validator-1"] 154 | - public_key: '853ee4db23d9ee501a651fbc900ba81fbf9397d914f1a7437afc247e7a666054d0197f02c1d12a76c43ee5c82784009f' 155 | labels: ["operator:kiln", "vc:teku-validator-1"] 156 | - public_key: 'b76f598fd5c28d742bc1a81af84f35f1284d62239989f1025e9eba9bece2d746a52f246f9bb6bcfde888b9f7b67fc4f6' 157 | labels: ["operator:kiln", "vc:teku-validator-1"] 158 | - public_key: 'afe3b6323ee16b10849404f2cb8eecc06ecef0c5ca05185f6640093948b36512d9896e7558dea0943d7e2eee8f65fdb1' 159 | labels: ["operator:kiln", "vc:teku-validator-1"] 160 | - public_key: '8eebee05702bf1574b12597b72a86d5badef064879fa9d1b9aff5ab75e5c71d81d8bc404f2614085855d6ed87f581238' 161 | labels: ["operator:kiln", "vc:teku-validator-1"] 162 | - public_key: 'a61cb5b148cb7ff34775dead8efa7d54d7141182356bf614070dfaa710ebf07a4dfb684dad151db60c0f8261c30a4f40' 163 | labels: ["operator:kiln", "vc:teku-validator-1"] 164 | - public_key: 'b7a2c83971c4e4132f3fcaf3c4374872de67ea5d89814492309cf924520a23787401f9621681fcf526154e80849a7e72' 165 | labels: ["operator:kiln", "vc:teku-validator-1"] 166 | - public_key: '8e70e4867d2731901d603928d72bbeb34b2e0339a4f5cf06e7a771640717421b4ea039c61dde951582a28c2ff152ff70' 167 | labels: ["operator:kiln", "vc:teku-validator-1"] 168 | - public_key: '8085c60b6b12ac8a5be8a7e24977663125c34827842aa3b2730854ab199dd0d2eaa93084c9599f0939be8db6758b198b' 169 | labels: ["operator:kiln", "vc:teku-validator-1"] 170 | - public_key: 'a57d5de556853484b1d88808d2529450238bc467376ded84cfd7b4a1ba258f6d43b5958220f962c57b033abcef1d5158' 171 | labels: ["operator:kiln", "vc:teku-validator-1"] 172 | - public_key: '903f569a8de771406b9fd36384f1fea20d5d79374b8d9af24b4814f96c44739193662aa47be857543fa101aa70ab205d' 173 | labels: ["operator:kiln", "vc:teku-validator-1"] 174 | - public_key: '85745bd84c92ddfc55df11fe134cf70e3c340aa1c7cdd6188a03308cf3a840f4f19629f9730b2e6426424989ff03000d' 175 | labels: ["operator:kiln", "vc:teku-validator-1"] 176 | - public_key: '84d3e2a06e16ced26094b356a16a4fb6aad50ad9ab23ef804a5852a33ef0bff76f3c5fbf7beb062376c2e669cb598679' 177 | labels: ["operator:kiln", "vc:teku-validator-1"] 178 | - public_key: 'af7cc29753903e70fcca8333fb7fadf4d7f6b8c20716bbb831815bbfab819b48c1e9b19148cf62392ad95c67c7bb0229' 179 | labels: ["operator:kiln", "vc:teku-validator-1"] 180 | - public_key: 'aebb24b64beafc6460ccd8445cee4a855b7656e98ba2cd11bd47c6303a243edc2cde1ddb09a9487b21db850479572b37' 181 | labels: ["operator:kiln", "vc:teku-validator-1"] 182 | - public_key: 'b0d4231814e40e53ab4eed8333d418a6e2e4bd3910148b610dec5f91961df1ad63f4661d533137a503d809ea1ad576fa' 183 | labels: ["operator:kiln", "vc:teku-validator-1"] 184 | - public_key: '81db8bf89aa98475a15a887c3c216690428609d09c22213b5d91cb34c7831b75ef95e219c5497c81cad1ce9da18ec41c' 185 | labels: ["operator:kiln", "vc:teku-validator-1"] 186 | - public_key: '9466afdb35d113733c0bc10b2e08ceba1132881c126524417602fc5a3fa4a626f6474b5f3f6c6dff49d74b9d8e91051b' 187 | labels: ["operator:kiln", "vc:teku-validator-1"] 188 | - public_key: '86ca8ed7c475d33455fae4242b05b1b3576e6ec05ac512ca7d3f9c8d44376e909c734c25cd0e33f0f6b4857d40452024' 189 | labels: ["operator:kiln", "vc:teku-validator-1"] 190 | - public_key: 'b01a30d439def99e676c097e5f4b2aa249aa4d184eaace81819a698cb37d33f5a24089339916ee0acb539f0e62936d83' 191 | labels: ["operator:kiln", "vc:teku-validator-1"] 192 | - public_key: '8d77e65ba6250fe18c54ce70d0ba4571a7d3e68a8b169055cd208e4434b35a4297e154775c73e7dfba511faadb2598c5' 193 | labels: ["operator:kiln", "vc:teku-validator-1"] 194 | - public_key: 'b9ee3b7b95db0122edd90b641de3c07fbf63a4f70fee5f72051cbe25c92d88444314d0489a5ecdb1805f4f149f462ee6' 195 | labels: ["operator:kiln", "vc:teku-validator-1"] 196 | - public_key: '87970b6946fc6f64010ce3e78de71a365814266707b23f871890dbdc6c5d1ad47dd3baa94da9eefc87523798cef84ff2' 197 | labels: ["operator:kiln", "vc:teku-validator-1"] 198 | - public_key: 'a0230bdf83cd469c7248074bec535eba8280cfde587d7c63d307149e9626bc7642b4bacc9beff2d8e8f6ea398dc0ade7' 199 | labels: ["operator:kiln", "vc:teku-validator-1"] 200 | - public_key: 'a3e1fe11f38d3954a7f48c8b68ff956ea0b6f8a3e603fd258c9406ec2b685ff48241db5257179ea020a83c31dc963854' 201 | labels: ["operator:kiln", "vc:teku-validator-1"] 202 | - public_key: '8ebfbcaccddd2489c4a29a374a2babc26987c3312607eadb2c4b0a53a17de97107c54eab34def09144b3098c082c286b' 203 | labels: ["operator:kiln", "vc:teku-validator-1"] 204 | - public_key: '95c0a30943ef34ef0a644439d857446e1c1736e18360f3f41803b0ca118e79af3fb9c608ec440a8de0f79d2c245b583c' 205 | labels: ["operator:kiln", "vc:teku-validator-1"] 206 | - public_key: '906cde18b34f777027d0c64b16c94c9d8f94250449d353e94972d42c94dd4d915aa1b6c73a581da2986e09f336af9673' 207 | labels: ["operator:kiln", "vc:teku-validator-1"] 208 | - public_key: 'a9ee291de5997232c68c9f6c3b568b05f46bfedfa18eb3776699d98cc7c6320003b7d862564d07fd28fc3691d1d28b21' 209 | labels: ["operator:kiln", "vc:teku-validator-1"] 210 | -------------------------------------------------------------------------------- /tests/assets/config.sepolia_replay_5_slots.yaml: -------------------------------------------------------------------------------- 1 | beacon_url: http://localhost:5052/ 2 | beacon_timeout_sec: 90 3 | network: sepolia 4 | metrics_port: 8000 5 | 6 | replay_start_at_ts: 1747989420 7 | replay_end_at_ts: 1747989985 8 | 9 | watched_keys: 10 | - public_key: '989fa046d04b41fc95a04dabb7ab8b64e84afaa85c0aa49e1c6878d7b2814094402d62ae42dfbf3ac72e6770ee0926a8' 11 | labels: ["operator:kiln", "vc:prysm-validator-1"] 12 | - public_key: '86edef59ab60ce98ae8d7a02e693970ca1cc6fa4372a59becc7ccca2a95e8f20a419899c8ccbb9c3e848f24178c15123' 13 | labels: ["operator:kiln", "vc:prysm-validator-1"] 14 | - public_key: '86bfb15c8155ec969dbdc6df4e310f32e89b0a9106941deaae52a299cf9a4fa6d7234f210e21ca1ab173025590507bb2' 15 | labels: ["operator:kiln", "vc:prysm-validator-1"] 16 | - public_key: 'b409f87f0632aae9bc081345b17a50a767ba4198f9ac9d352246fb3bebd29ed53c9d6f148c2f318c2eb12846b0aac4cb' 17 | labels: ["operator:kiln", "vc:prysm-validator-1"] 18 | - public_key: 'a5817c74a394b0359a4376ef7e9e8f7dfa6a7829602da225074fb392b715e1fd52c50cae0f128a7006f28b22f233fbf5' 19 | labels: ["operator:kiln", "vc:prysm-validator-1"] 20 | - public_key: 'b09d7c4e74e45aa7fa9f7ffd32e3420e6e4e373217ea824ff0723ec0574d0a5575b6dbca7b98c5ab7b981299e315099e' 21 | labels: ["operator:kiln", "vc:prysm-validator-1"] 22 | - public_key: 'abd7248ae069d3a3a45b0ef4dd5d7d54b62994e578ea20bdd3b7876596673953b94c5b109a6e4b953b517544b915368f' 23 | labels: ["operator:kiln", "vc:prysm-validator-1"] 24 | - public_key: '95d1f944b0c53eb3e9fcd5632713602bbb9195b87a172a370ae2df98504612a55f3968615a39b569ce6a0fe9fb559be7' 25 | labels: ["operator:kiln", "vc:prysm-validator-1"] 26 | - public_key: 'a77f96ae68fe39b3ae3260de804cf348d12c954c3320c07e411b95104da25882b414d282a98bbfbf3dff77442244e887' 27 | labels: ["operator:kiln", "vc:prysm-validator-1"] 28 | - public_key: '8f0f48114501787a622dfb4bf1de666280e3e592101c59f207b1cd7514bbde8a13e95f2b3f09af291b68b9140c1d9137' 29 | labels: ["operator:kiln", "vc:prysm-validator-1"] 30 | - public_key: 'b8876bda1e709ab16e1347a1107852a7898a334a84af978de39920790b4d82eb0739cbfc34da1c7154dd6e9f7674759c' 31 | labels: ["operator:kiln", "vc:prysm-validator-1"] 32 | - public_key: 'af861bb21f84c3e7cab55370839d18ac24c475a9e833607fcd7e65c773ee9d64bea203ee9e8fd9d337f1cd28d5ca0d90' 33 | labels: ["operator:kiln", "vc:prysm-validator-1"] 34 | - public_key: 'b4d5ad2fa79ce408d9b13523764ad5c7c6c7ffe96fdf1988658ef7baf28118b33d48eb9c3e21d1951fd4499f196d2f0a' 35 | labels: ["operator:kiln", "vc:prysm-validator-1"] 36 | - public_key: 'ab7c058199294c02e1edf9b790004f971cb8c41ae7efd25592705970141cdd5318e8eb187959f1ac8bf45c59f1ead0d9' 37 | labels: ["operator:kiln", "vc:prysm-validator-1"] 38 | - public_key: '88ad79a0320a896415e15b827a89969b4590d4dfa269b662bdc8f4633618f67b249f7e35a35884b131772d08025bfc32' 39 | labels: ["operator:kiln", "vc:prysm-validator-1"] 40 | - public_key: 'a9d47cb4c69fde551b2648a2444091502a56a778212ab544ac75cc1bd14d0f043f4e31de47fce9a890ef5428cc28dd41' 41 | labels: ["operator:kiln", "vc:prysm-validator-1"] 42 | - public_key: 'a0ea0827b17130cae727928ad22dca3a844beebee3f11b2e511782f8bbc8773ca9eb351348f7711fa1f5aba0b29190d4' 43 | labels: ["operator:kiln", "vc:prysm-validator-1"] 44 | - public_key: '84a6edac5ac68a7ca837c46d5ada8fab136748b6c3a3b9165dbbc231ec386b15328e4ef7d69a15d4cf354135348a4ee4' 45 | labels: ["operator:kiln", "vc:prysm-validator-1"] 46 | - public_key: '898deb30ede570d391266c81132a78239083aa9e27a9068e26a3bc14ff6468c3f2423484efb2f808b4996c16bfee0932' 47 | labels: ["operator:kiln", "vc:prysm-validator-1"] 48 | - public_key: 'b1632f726d2aea275be4d132e0cda008caf03c91640959b3c62568d87c24adbeb6883a32828bfa99abeca8294cc5e9ce' 49 | labels: ["operator:kiln", "vc:prysm-validator-1"] 50 | - public_key: 'a2040b80ceba0fad581f904f743e620f78172af026a9ad5ecc2f627f0181ab10c6cee238b07d1ba0e459c97bb85f7f48' 51 | labels: ["operator:kiln", "vc:prysm-validator-1"] 52 | - public_key: 'ad7d2e3820e9c9afb8afe3d01b62bf7e05d1d5c3697045562059a4421892e37515ad87251c780f917e3cc72fbd318be5' 53 | labels: ["operator:kiln", "vc:prysm-validator-1"] 54 | - public_key: '8f9aededb605db4e499d3c383b0984b1322007c748dea18dc2f1c73da104a5c0bece6bb41d83abdfac594954801b6b62' 55 | labels: ["operator:kiln", "vc:prysm-validator-1"] 56 | - public_key: '908d762396519ce3c409551b3b5915033cdfe521a586d5c17f49c1d2faa6cb59fa51e1fb74f200487bea87a1d6f37477' 57 | labels: ["operator:kiln", "vc:prysm-validator-1"] 58 | - public_key: '8bb045e7482b7abe670d72eb2f7afe4207b5a3d488364ff7bb4266f8784ea41893553a4bf7d01e78c99ed9008e2c13bb' 59 | labels: ["operator:kiln", "vc:prysm-validator-1"] 60 | - public_key: '95791fb6b08443445b8757906f3a2b1a8414a9016b5f8059c577752b701d6dc1fe9b784bac1fa57a1446b7adfd11c868' 61 | labels: ["operator:kiln", "vc:prysm-validator-1"] 62 | - public_key: 'ac7983d50ec447b65e62ed38054d8e8242c31b40030f630098ce0a4e93536da9179c3f3ae0b34a0b02aad427a97ee60d' 63 | labels: ["operator:kiln", "vc:prysm-validator-1"] 64 | - public_key: 'b2235bdf60dde5d0d78c72cb69e6e09153b0154efdbab97e1bc91f18d3cec4f660a80311fe6a1acd419a448ab65b18f1' 65 | labels: ["operator:kiln", "vc:prysm-validator-1"] 66 | - public_key: 'a7e8775e04214e3b9898ffb9625dc8bcd1b683e333acdceddb8ca6db241df08a7b80e9d476a711b8b7d66aefca81e9cd' 67 | labels: ["operator:kiln", "vc:prysm-validator-1"] 68 | - public_key: '847b58626f306ef2d785e3fe1b6515f98d9f72037eea0604d92e891a0219142fec485323bec4e93a4ee132af61026b80' 69 | labels: ["operator:kiln", "vc:prysm-validator-1"] 70 | - public_key: 'b576c49c2a7b7c3445bbf9ba8eac10e685cc3760d6819de43b7d1e20769772bcab9f557df96f28fd24409ac8c84d05c4' 71 | labels: ["operator:kiln", "vc:prysm-validator-1"] 72 | - public_key: 'a0617db822d559764a23c4361e849534d4b411e2cf9e1c4132c1104085175aa5f2ce475a6d1d5cb178056945ca782182' 73 | labels: ["operator:kiln", "vc:prysm-validator-1"] 74 | - public_key: '88b49b1130f9df26407ff3f6ac10539a6a67b6ddcc73eaf27fe2a18fb69aa2aff0581a5b0eef96b9ddd3cb761bdbbf51' 75 | labels: ["operator:kiln", "vc:prysm-validator-1"] 76 | - public_key: '838d5eee51f5d65c9ed1632d042bb7f88161f3789e6bb461318c5400eaf6728e7ba0f92c18e1a994aa4743145c96164b' 77 | labels: ["operator:kiln", "vc:prysm-validator-1"] 78 | - public_key: 'a07826925f401a7b4222d869bb8794b5714ef2fc66fba2b1170fcac98bed4ba85d976cf9ee268be8a349ae99e17ac075' 79 | labels: ["operator:kiln", "vc:prysm-validator-1"] 80 | - public_key: '83bbd31e799ac14686085868e8ea7587c7c7969c7015bfe45fd8e3a3847ad5338005f9cdf58396b2ea833c4af98bd9ca' 81 | labels: ["operator:kiln", "vc:prysm-validator-1"] 82 | - public_key: '8effe3fb27c9f76bbd78687b743b52e6f3330eddc81bc9006ca81fd640f149d73630af578694f4530833c2151522dcc1' 83 | labels: ["operator:kiln", "vc:prysm-validator-1"] 84 | - public_key: '8e2a281e944a28673fb8b47aaa288375cefd3a6be20e453131d85363ecc4fd5b250e7f9d7ca1e53408c54943041945a2' 85 | labels: ["operator:kiln", "vc:prysm-validator-1"] 86 | - public_key: 'a26dd9b28564c3d95679aca03e3432ac26e287f80e870714c5946b05538b3cb43bba7b85c16bceb5430e81b7a04c1b1d' 87 | labels: ["operator:kiln", "vc:prysm-validator-1"] 88 | - public_key: '8d797819318cdf7b26405d1a327d80d4c289e56f830b28d4e303bcb019aeb0b3d69bfed58adcde8a2445dd5281b86af1' 89 | labels: ["operator:kiln", "vc:prysm-validator-1"] 90 | - public_key: '8853eff72fa4c7b4eda77e448e12bc8ee75f5cb0f35b721c7ee8184cf030a11e3e0278a4e76b326416fd645a9645d901' 91 | labels: ["operator:kiln", "vc:prysm-validator-1"] 92 | - public_key: 'a2ab566033062b6481eb7e4bbc64ed022407f56aa8dddc1aade76aa54a30ce3256052ce99218b66e6265f70837137a10' 93 | labels: ["operator:kiln", "vc:prysm-validator-1"] 94 | - public_key: '8fed283861ce42b3151d60887d0d3d2ff69869c051aed304af0f1db3f57dabf32f2b6994d9f0f11478eefbbb1daf9a8a' 95 | labels: ["operator:kiln", "vc:prysm-validator-1"] 96 | - public_key: 'a8b742cb7f497adfb99bdc6bcaf7f4bdadded2a6d5958680406b3b00545e1812d78e03e20be42b471b1daba84587d574' 97 | labels: ["operator:kiln", "vc:prysm-validator-1"] 98 | - public_key: '96b1c82b85cdb8a7026fd3431bea9cd008f0261ee7f4179f4e69a399872837ab836a14e2dd45f5448d54800a4ae7c7f2' 99 | labels: ["operator:kiln", "vc:prysm-validator-1"] 100 | - public_key: '86cef0506d35ac8afa7509561aa90bbc89663f7f880a86b0aa838464a33a36f27808cd8b68fa6f729e6eede4ab0583da' 101 | labels: ["operator:kiln", "vc:prysm-validator-1"] 102 | - public_key: 'a57bacada151d6521c6f40371e80cc8e44bb76389dfa7da5deba7675bb9a253c59a901df26c6f1069205b37f18048b1c' 103 | labels: ["operator:kiln", "vc:prysm-validator-1"] 104 | - public_key: 'a778da56ddfe4a383816b43b027464d7a28689fc4a6b35b36883d3f36d9c41f0177bdbfc8f258afe8da90f02d3b64fea' 105 | labels: ["operator:kiln", "vc:prysm-validator-1"] 106 | - public_key: 'a4e2df74c8e7257e3df1e4f6a9ad4141c8299f43f02bcc53bfeeaa1698faecf81a4ad2be7f5ddbd1be657c87110ea34c' 107 | labels: ["operator:kiln", "vc:prysm-validator-1"] 108 | - public_key: 'a4a7dd1aaf815c271a639b044efffd5bf575a22cf895adf5813d5ab1b63a60e5bfb08d275e890ec695979a0256e4cfc7' 109 | labels: ["operator:kiln", "vc:prysm-validator-1"] 110 | - public_key: 'a8f2572a2cc2ecba151a3d5f4040a70172067ddadd8c12ba9d60f993eb0eab6698cb35932949c9a42e45b36a822af40e' 111 | labels: ["operator:kiln", "vc:teku-validator-1"] 112 | - public_key: '993726e0b1c2277b97b83c80192e14b67977bf21b6ebcde2bda30261aa1897251cd2e277cfcb6193517f1eb156d2fe86' 113 | labels: ["operator:kiln", "vc:teku-validator-1"] 114 | - public_key: 'b6cacc458ca5a0f04836c5640d34c70cab34cb5c87df28579a0d5e2c72edbc092814bdbe902747ebe3ce36808d8f4dac' 115 | labels: ["operator:kiln", "vc:teku-validator-1"] 116 | - public_key: 'ab6b47627cf76d9552c723818db5ebee7734542436b50ffe15b3a96e8e7a6b54f9a0965de78405e16e309193f147108d' 117 | labels: ["operator:kiln", "vc:teku-validator-1"] 118 | - public_key: '86b3a4ea9b1fde00cce79d5ae480353d60cb6ddce363c535bbbc3e41a4b8e39fcf2978eb430091ae1b10420d43193971' 119 | labels: ["operator:kiln", "vc:teku-validator-1"] 120 | - public_key: '8f90e72a54e6894d511061957162e753010812346afd4d90cfedb678b99ba1aacf2b6bd0e49b4b0e684da8082a048619' 121 | labels: ["operator:kiln", "vc:teku-validator-1"] 122 | - public_key: 'b1cca4f417063a861f6c5b4bbe2b129bc72003de58bab895325283ff5f1045af808da9048fa72217863e3de5ac87286d' 123 | labels: ["operator:kiln", "vc:teku-validator-1"] 124 | - public_key: '8c6fc89428c74f0c025e980c5a1e576deadf8685f57136e50600175fa2d19389c853d532bb45a3e22b4a879fab1fcb0d' 125 | labels: ["operator:kiln", "vc:teku-validator-1"] 126 | - public_key: '8c7ccbea47f3fb6c15863c84c99a9094a00f2b5836200eeb73dbf84fc8e7856369dc7ab09f9d51ae42909fa94c895afc' 127 | labels: ["operator:kiln", "vc:teku-validator-1"] 128 | - public_key: '8163eea18eacc062e71bb9f7406c58ebe1ce42a8b93656077dd781c2772e37775fe20e8d5b980dd52fdad98b72f10b71' 129 | labels: ["operator:kiln", "vc:teku-validator-1"] 130 | - public_key: 'b4a1d185c770ed41021ab0497a2ecf724fbd046784418b8a4af8d654dd9b10c2f3333e6f4f9e6ce385916546a2cb6a8e' 131 | labels: ["operator:kiln", "vc:teku-validator-1"] 132 | - public_key: 'b58396bce7d32ba6c70adbd37156d859e153c1932d2b0c7c874a1182ba831439e80d6fc6d7d88a870e193f515aef2264' 133 | labels: ["operator:kiln", "vc:teku-validator-1"] 134 | - public_key: '93e4d7740847caeeaca68e0b8f9a81b9475435108861506e3d3ccd3d716e05ced294ac30743eb9f45496acd6438b255d' 135 | labels: ["operator:kiln", "vc:teku-validator-1"] 136 | - public_key: '8a9f7e8d45f11c4bfb0921c6008f3c79ff923452bcfa7769beb3222f1f37dcb861be979e6eae187f06cf26af05e8ee5b' 137 | labels: ["operator:kiln", "vc:teku-validator-1"] 138 | - public_key: '966256693e9cd01d67855d9a834f39a8e7628f531e136b5113b7cdb91e17b554fcbef2611929b74710606585b1df59b5' 139 | labels: ["operator:kiln", "vc:teku-validator-1"] 140 | - public_key: '8910f41db6952c25dfbf6b6b5ba252a2d999c51537d35a0d86b7688bb54dcb6f11eb755a5dce366113dfb2f6b56802b7' 141 | labels: ["operator:kiln", "vc:teku-validator-1"] 142 | - public_key: '8100b48ac2785477a123a7967bfcea8bacef59391680a411692880098a08771ff9786bd3b8dfb034cae00d5a7665621c' 143 | labels: ["operator:kiln", "vc:teku-validator-1"] 144 | - public_key: 'aeeedb3c73a9eadef14396a474ca83ca9e3885fd5f2c1018652360481d0be49524de22fc1ea18bb7abca66df5dc7d309' 145 | labels: ["operator:kiln", "vc:teku-validator-1"] 146 | - public_key: 'a8e03a26e88e4ed03751ccf6eeed6215becbf4c2d58be27361f61d1cc4ac9b692fc6ecdb839f9b3c17f54fc2f2f4756e' 147 | labels: ["operator:kiln", "vc:teku-validator-1"] 148 | - public_key: '8600e2031c9113ad2a75c19872b5efef85765b524f74de98baf4efe4a75c6be563e9e19622388fbe9afe58aa6017b930' 149 | labels: ["operator:kiln", "vc:teku-validator-1"] 150 | - public_key: '982d84a38d17b96d5729456c60f76efc9aaa0fccf66d99d5949b1f09a8867dee10ab70fb1b317fa4a794173d9ca95b16' 151 | labels: ["operator:kiln", "vc:teku-validator-1"] 152 | - public_key: 'b8aba8f15ea91d23e660736ac87f3641f5233911ca6ca65805ad6890436ebc561555429407ba6b1b39ccf3a917a03dd8' 153 | labels: ["operator:kiln", "vc:teku-validator-1"] 154 | - public_key: '853ee4db23d9ee501a651fbc900ba81fbf9397d914f1a7437afc247e7a666054d0197f02c1d12a76c43ee5c82784009f' 155 | labels: ["operator:kiln", "vc:teku-validator-1"] 156 | - public_key: 'b76f598fd5c28d742bc1a81af84f35f1284d62239989f1025e9eba9bece2d746a52f246f9bb6bcfde888b9f7b67fc4f6' 157 | labels: ["operator:kiln", "vc:teku-validator-1"] 158 | - public_key: 'afe3b6323ee16b10849404f2cb8eecc06ecef0c5ca05185f6640093948b36512d9896e7558dea0943d7e2eee8f65fdb1' 159 | labels: ["operator:kiln", "vc:teku-validator-1"] 160 | - public_key: '8eebee05702bf1574b12597b72a86d5badef064879fa9d1b9aff5ab75e5c71d81d8bc404f2614085855d6ed87f581238' 161 | labels: ["operator:kiln", "vc:teku-validator-1"] 162 | - public_key: 'a61cb5b148cb7ff34775dead8efa7d54d7141182356bf614070dfaa710ebf07a4dfb684dad151db60c0f8261c30a4f40' 163 | labels: ["operator:kiln", "vc:teku-validator-1"] 164 | - public_key: 'b7a2c83971c4e4132f3fcaf3c4374872de67ea5d89814492309cf924520a23787401f9621681fcf526154e80849a7e72' 165 | labels: ["operator:kiln", "vc:teku-validator-1"] 166 | - public_key: '8e70e4867d2731901d603928d72bbeb34b2e0339a4f5cf06e7a771640717421b4ea039c61dde951582a28c2ff152ff70' 167 | labels: ["operator:kiln", "vc:teku-validator-1"] 168 | - public_key: '8085c60b6b12ac8a5be8a7e24977663125c34827842aa3b2730854ab199dd0d2eaa93084c9599f0939be8db6758b198b' 169 | labels: ["operator:kiln", "vc:teku-validator-1"] 170 | - public_key: 'a57d5de556853484b1d88808d2529450238bc467376ded84cfd7b4a1ba258f6d43b5958220f962c57b033abcef1d5158' 171 | labels: ["operator:kiln", "vc:teku-validator-1"] 172 | - public_key: '903f569a8de771406b9fd36384f1fea20d5d79374b8d9af24b4814f96c44739193662aa47be857543fa101aa70ab205d' 173 | labels: ["operator:kiln", "vc:teku-validator-1"] 174 | - public_key: '85745bd84c92ddfc55df11fe134cf70e3c340aa1c7cdd6188a03308cf3a840f4f19629f9730b2e6426424989ff03000d' 175 | labels: ["operator:kiln", "vc:teku-validator-1"] 176 | - public_key: '84d3e2a06e16ced26094b356a16a4fb6aad50ad9ab23ef804a5852a33ef0bff76f3c5fbf7beb062376c2e669cb598679' 177 | labels: ["operator:kiln", "vc:teku-validator-1"] 178 | - public_key: 'af7cc29753903e70fcca8333fb7fadf4d7f6b8c20716bbb831815bbfab819b48c1e9b19148cf62392ad95c67c7bb0229' 179 | labels: ["operator:kiln", "vc:teku-validator-1"] 180 | - public_key: 'aebb24b64beafc6460ccd8445cee4a855b7656e98ba2cd11bd47c6303a243edc2cde1ddb09a9487b21db850479572b37' 181 | labels: ["operator:kiln", "vc:teku-validator-1"] 182 | - public_key: 'b0d4231814e40e53ab4eed8333d418a6e2e4bd3910148b610dec5f91961df1ad63f4661d533137a503d809ea1ad576fa' 183 | labels: ["operator:kiln", "vc:teku-validator-1"] 184 | - public_key: '81db8bf89aa98475a15a887c3c216690428609d09c22213b5d91cb34c7831b75ef95e219c5497c81cad1ce9da18ec41c' 185 | labels: ["operator:kiln", "vc:teku-validator-1"] 186 | - public_key: '9466afdb35d113733c0bc10b2e08ceba1132881c126524417602fc5a3fa4a626f6474b5f3f6c6dff49d74b9d8e91051b' 187 | labels: ["operator:kiln", "vc:teku-validator-1"] 188 | - public_key: '86ca8ed7c475d33455fae4242b05b1b3576e6ec05ac512ca7d3f9c8d44376e909c734c25cd0e33f0f6b4857d40452024' 189 | labels: ["operator:kiln", "vc:teku-validator-1"] 190 | - public_key: 'b01a30d439def99e676c097e5f4b2aa249aa4d184eaace81819a698cb37d33f5a24089339916ee0acb539f0e62936d83' 191 | labels: ["operator:kiln", "vc:teku-validator-1"] 192 | - public_key: '8d77e65ba6250fe18c54ce70d0ba4571a7d3e68a8b169055cd208e4434b35a4297e154775c73e7dfba511faadb2598c5' 193 | labels: ["operator:kiln", "vc:teku-validator-1"] 194 | - public_key: 'b9ee3b7b95db0122edd90b641de3c07fbf63a4f70fee5f72051cbe25c92d88444314d0489a5ecdb1805f4f149f462ee6' 195 | labels: ["operator:kiln", "vc:teku-validator-1"] 196 | - public_key: '87970b6946fc6f64010ce3e78de71a365814266707b23f871890dbdc6c5d1ad47dd3baa94da9eefc87523798cef84ff2' 197 | labels: ["operator:kiln", "vc:teku-validator-1"] 198 | - public_key: 'a0230bdf83cd469c7248074bec535eba8280cfde587d7c63d307149e9626bc7642b4bacc9beff2d8e8f6ea398dc0ade7' 199 | labels: ["operator:kiln", "vc:teku-validator-1"] 200 | - public_key: 'a3e1fe11f38d3954a7f48c8b68ff956ea0b6f8a3e603fd258c9406ec2b685ff48241db5257179ea020a83c31dc963854' 201 | labels: ["operator:kiln", "vc:teku-validator-1"] 202 | - public_key: '8ebfbcaccddd2489c4a29a374a2babc26987c3312607eadb2c4b0a53a17de97107c54eab34def09144b3098c082c286b' 203 | labels: ["operator:kiln", "vc:teku-validator-1"] 204 | - public_key: '95c0a30943ef34ef0a644439d857446e1c1736e18360f3f41803b0ca118e79af3fb9c608ec440a8de0f79d2c245b583c' 205 | labels: ["operator:kiln", "vc:teku-validator-1"] 206 | - public_key: '906cde18b34f777027d0c64b16c94c9d8f94250449d353e94972d42c94dd4d915aa1b6c73a581da2986e09f336af9673' 207 | labels: ["operator:kiln", "vc:teku-validator-1"] 208 | - public_key: 'a9ee291de5997232c68c9f6c3b568b05f46bfedfa18eb3776699d98cc7c6320003b7d862564d07fd28fc3691d1d28b21' 209 | labels: ["operator:kiln", "vc:teku-validator-1"] 210 | -------------------------------------------------------------------------------- /tests/assets/config.yaml: -------------------------------------------------------------------------------- 1 | # Example config file for the Ethereum watcher. 2 | 3 | beacon_url: http://localhost:5051/ 4 | beacon_timeout_sec: 90 5 | network: holesky 6 | metrics_port: 4242 7 | 8 | watched_keys: 9 | - public_key: '0x832b8286f5d6535fd941c6c4ed8b9b20d214fc6aa726ce4fba1c9dbb4f278132646304f550e557231b6932aa02cf08d3' 10 | labels: ["google"] 11 | -------------------------------------------------------------------------------- /tests/assets/sepolia_header_4996301.json: -------------------------------------------------------------------------------- 1 | {"data":{"root":"0x80e0a252b97aed4b6e123012251e05338df4734cb000ac360838fd836adb521e","canonical":true,"header":{"message":{"slot":"4996301","proposer_index":"253","parent_root":"0x2b25f247c878f483f1b57b08739e80304a6fa59277ae9207a1a818235cf427c6","state_root":"0x8d76d8dc2bcf1c656b04d397457b3924950ae78f7b076705bcc1d4b8689eac1a","body_root":"0x43e3dba3b1c79aecb915f84d37ce5b2d96216ad7440a77fdc66069845f1e7de8"},"signature":"0x86a5304c52a150ef2c3f8e030b927c565ac865eb0e4989305f01dea02754a7b242938e43e48d6b99d771fa7f4c0cdedc11251283dbc1d9d951c8ff39bba9f074441bdc91d379ea59b93da9e4392508e77ee7c3c98f5ace88275e34083b9405bd"}},"execution_optimistic":false,"finalized":false} -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from eth_validator_watcher.config import load_config 4 | from tests import assets 5 | 6 | 7 | def test_null_config() -> None: 8 | path = str(Path(assets.__file__).parent / "config.null.yaml") 9 | config = load_config(path) 10 | 11 | assert config.beacon_url == 'http://localhost:5051/' 12 | assert config.metrics_port == 8000 13 | assert config.beacon_timeout_sec == 90 14 | assert config.network == 'mainnet' 15 | 16 | assert config.watched_keys == [] 17 | 18 | 19 | def test_empty_config() -> None: 20 | path = str(Path(assets.__file__).parent / "config.empty.yaml") 21 | config = load_config(path) 22 | 23 | assert config.beacon_url == 'http://localhost:5051/' 24 | assert config.beacon_timeout_sec == 90 25 | assert config.metrics_port == 8000 26 | assert config.network == 'mainnet' 27 | assert config.replay_start_at_ts is None 28 | assert config.replay_end_at_ts is None 29 | 30 | assert config.watched_keys == [] 31 | 32 | 33 | def test_filled_config() -> None: 34 | path = str(Path(assets.__file__).parent / "config.yaml") 35 | config = load_config(path) 36 | 37 | assert config.beacon_url == 'http://localhost:5051/' 38 | assert config.beacon_timeout_sec == 90 39 | assert config.metrics_port == 4242 40 | assert config.network == 'holesky' 41 | assert config.replay_start_at_ts is None 42 | assert config.replay_end_at_ts is None 43 | 44 | assert [k.public_key for k in config.watched_keys] == ['0x832b8286f5d6535fd941c6c4ed8b9b20d214fc6aa726ce4fba1c9dbb4f278132646304f550e557231b6932aa02cf08d3'] 45 | 46 | 47 | def test_filled_config_overriden() -> None: 48 | environ = os.environ.copy() 49 | 50 | os.environ['eth_watcher_beacon_url'] = 'http://override-beacon/' 51 | os.environ['eth_watcher_beacon_timeout_sec'] = '42' 52 | os.environ['eth_watcher_network'] = 'sepolia' 53 | 54 | path = str(Path(assets.__file__).parent / "config.yaml") 55 | config = load_config(path) 56 | 57 | assert config.beacon_url == 'http://override-beacon/' 58 | assert config.beacon_timeout_sec == 42 59 | assert config.network == 'sepolia' 60 | 61 | assert [k.public_key for k in config.watched_keys] == ['0x832b8286f5d6535fd941c6c4ed8b9b20d214fc6aa726ce4fba1c9dbb4f278132646304f550e557231b6932aa02cf08d3'] 62 | 63 | os.environ.clear() 64 | os.environ.update(environ) 65 | -------------------------------------------------------------------------------- /tests/test_hexbits.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from eth_validator_watcher.duties import bitfield_to_bitstring 4 | 5 | 6 | class HexBitsTestCase(unittest.TestCase): 7 | """Test case for hex_to_sparse_bitset function.""" 8 | 9 | def test_hex_to_sparse_bitset_zero(self) -> None: 10 | """Test hex_to_sparse_bitset() with a zero hex value.""" 11 | result = bitfield_to_bitstring("0x0000064814008019", False) 12 | indices = {i for i, bit in enumerate(result) if bit == "1"} 13 | self.assertEqual(indices, {17, 18, 27, 30, 34, 36, 55, 56, 59, 60}) 14 | 15 | 16 | if __name__ == "__main__": 17 | unittest.main() 18 | -------------------------------------------------------------------------------- /tests/test_sepolia.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import vcr 3 | 4 | from functools import wraps 5 | from pathlib import Path 6 | from tests import assets 7 | from vcr.unittest import VCRTestCase 8 | 9 | from eth_validator_watcher.entrypoint import ValidatorWatcher 10 | 11 | 12 | def sepolia_test(config_path: str): 13 | """Decorator to "simplify" a bit the writing of unit tests. 14 | 15 | Tests using it will see their method called at the end of each 16 | slot processing on the watcher, with the dict `self.metrics` 17 | filled with what's exposed on prometheus. 18 | 19 | Example: 20 | 21 | @sepolia_test('config_path'): 22 | def my_test(self, slot: int): 23 | self.assertEqual(self.metrics['eth_slot{network="sepolia"}', float(slot))) 24 | 25 | """ 26 | 27 | def wrapper(f): 28 | @wraps(f) 29 | def _run_test(self, *args, **kwargs): 30 | with self.vcr.use_cassette('tests/assets/cassettes/test_sepolia.yaml'): 31 | 32 | self.watcher = ValidatorWatcher( 33 | Path(assets.__file__).parent / config_path 34 | ) 35 | 36 | def h(slot: int): 37 | self.slot_hook_calls += 1 38 | self.metrics = self._get_metrics() 39 | self.assertIsNone(f(self, slot)) 40 | 41 | self.watcher._slot_hook = h 42 | self.slot_hook_calls = 0 43 | self.watcher.run() 44 | self.assertGreater(self.slot_hook_calls, 0) 45 | 46 | return _run_test 47 | 48 | return wrapper 49 | 50 | 51 | class SepoliaTestCase(VCRTestCase): 52 | """This is a series of full end-to-end test. 53 | 54 | We mock a beacon with data recorded with cassette during ~2-3 55 | epochs, slightly adapted to expose specific edge cases. The 56 | available beacon data spans between: 57 | 58 | - slot 5493884 (timestamp=1721660208) 59 | - slot 6356780 (timestamp=1721661324) 60 | """ 61 | 62 | def setUp(self): 63 | 64 | def ignore_metrics_cb(request): 65 | # Do not mock /metrics endpoint as this is exposed by our 66 | # service and we need it to validate metrics we expose are 67 | # correct. 68 | if request.uri == 'http://localhost:8000/metrics': 69 | return None 70 | return request 71 | 72 | self.vcr = vcr.VCR(before_record=ignore_metrics_cb) 73 | 74 | def tearDown(self): 75 | pass 76 | 77 | def _get_metrics(self): 78 | url = 'http://localhost:8000/metrics' 79 | response = requests.get(url) 80 | self.assertEqual(response.status_code, 200) 81 | result = {} 82 | for line in response.text.split('\n'): 83 | line = line.strip() 84 | if line and not line.startswith('#'): 85 | key, value = line.split(' ') 86 | result[key] = float(value) 87 | return result 88 | 89 | def print_matching_metric(self, pattern: str): 90 | """Helper to print matching metrics. 91 | """ 92 | for k, v in self.metrics.items(): 93 | if pattern in k: 94 | print(f'{k}: {v}') 95 | 96 | @sepolia_test("config.sepolia_replay_2_slots.yaml") 97 | def test_sepolia_metric_slot(self, slot: int): 98 | """Verifies the slot metric is exposed. 99 | """ 100 | self.assertEqual(float(slot), self.metrics['eth_slot{network="sepolia"}']) 101 | 102 | @sepolia_test("config.sepolia_replay_2_slots.yaml") 103 | def test_sepolia_metric_epoch(self, slot: int): 104 | """Verifies the epoch metric is exposed. 105 | """ 106 | self.assertEqual(int(slot) // 32, self.metrics['eth_epoch{network="sepolia"}']) 107 | 108 | @sepolia_test("config.sepolia_replay_2_slots.yaml") 109 | def test_sepolia_validator_status(self, slot: int): 110 | """Verifies the validator statuses are exposed by scopes. 111 | """ 112 | if slot != 7363592: 113 | return 114 | 115 | def test_for_label( 116 | label: str, 117 | pending_initialized: int = 0, 118 | pending_queued: int = 0, 119 | active_ongoing: int = 0, 120 | active_exiting: int = 0, 121 | active_slashed: int = 0, 122 | exited_unslashed: int = 0, 123 | exited_slashed: int = 0, 124 | withdrawal_possible: int = 0, 125 | withdrawal_done: int = 0, 126 | ) -> None: 127 | 128 | self.assertEqual(self.metrics[f'eth_validator_status_count{{network="sepolia",scope="{label}",status="pending_initialized"}}'], float(pending_initialized)) 129 | self.assertEqual(self.metrics[f'eth_validator_status_count{{network="sepolia",scope="{label}",status="pending_queued"}}'], float(pending_queued)) 130 | self.assertEqual(self.metrics[f'eth_validator_status_count{{network="sepolia",scope="{label}",status="active_ongoing"}}'], float(active_ongoing)) 131 | self.assertEqual(self.metrics[f'eth_validator_status_count{{network="sepolia",scope="{label}",status="active_exiting"}}'], float(active_exiting)) 132 | self.assertEqual(self.metrics[f'eth_validator_status_count{{network="sepolia",scope="{label}",status="active_slashed"}}'], float(active_slashed)) 133 | self.assertEqual(self.metrics[f'eth_validator_status_count{{network="sepolia",scope="{label}",status="exited_unslashed"}}'], float(exited_unslashed)) 134 | self.assertEqual(self.metrics[f'eth_validator_status_count{{network="sepolia",scope="{label}",status="exited_slashed"}}'], float(exited_slashed)) 135 | self.assertEqual(self.metrics[f'eth_validator_status_count{{network="sepolia",scope="{label}",status="withdrawal_possible"}}'], float(withdrawal_possible)) 136 | self.assertEqual(self.metrics[f'eth_validator_status_count{{network="sepolia",scope="{label}",status="withdrawal_done"}}'], float(withdrawal_done)) 137 | 138 | test_for_label("scope:watched", active_ongoing=100) 139 | test_for_label("scope:all-network", active_ongoing=1781, withdrawal_possible=200, withdrawal_done=6) 140 | test_for_label("scope:network", active_ongoing=1681, withdrawal_possible=200, withdrawal_done=6) 141 | test_for_label("operator:kiln", active_ongoing=100) 142 | test_for_label("vc:prysm-validator-1", active_ongoing=50) 143 | test_for_label("vc:teku-validator-1", active_ongoing=50) 144 | 145 | @sepolia_test("config.sepolia_replay_2_slots.yaml") 146 | def test_sepolia_validator_scaled_status(self, slot: int): 147 | """Verifies the validator statuses are exposed by scopes. 148 | """ 149 | if slot != 7363592: 150 | return 151 | 152 | def test_for_label( 153 | label: str, 154 | pending_initialized: int = 0, 155 | pending_queued: int = 0, 156 | active_ongoing: int = 0, 157 | active_exiting: int = 0, 158 | active_slashed: int = 0, 159 | exited_unslashed: int = 0, 160 | exited_slashed: int = 0, 161 | withdrawal_possible: int = 0, 162 | withdrawal_done: int = 0, 163 | ) -> None: 164 | 165 | self.assertEqual(self.metrics[f'eth_validator_status_scaled_count{{network="sepolia",scope="{label}",status="pending_initialized"}}'], float(pending_initialized)) 166 | self.assertEqual(self.metrics[f'eth_validator_status_scaled_count{{network="sepolia",scope="{label}",status="pending_queued"}}'], float(pending_queued)) 167 | self.assertEqual(self.metrics[f'eth_validator_status_scaled_count{{network="sepolia",scope="{label}",status="active_ongoing"}}'], float(active_ongoing)) 168 | self.assertEqual(self.metrics[f'eth_validator_status_scaled_count{{network="sepolia",scope="{label}",status="active_exiting"}}'], float(active_exiting)) 169 | self.assertEqual(self.metrics[f'eth_validator_status_scaled_count{{network="sepolia",scope="{label}",status="active_slashed"}}'], float(active_slashed)) 170 | self.assertEqual(self.metrics[f'eth_validator_status_scaled_count{{network="sepolia",scope="{label}",status="exited_unslashed"}}'], float(exited_unslashed)) 171 | self.assertEqual(self.metrics[f'eth_validator_status_scaled_count{{network="sepolia",scope="{label}",status="exited_slashed"}}'], float(exited_slashed)) 172 | self.assertEqual(self.metrics[f'eth_validator_status_scaled_count{{network="sepolia",scope="{label}",status="withdrawal_possible"}}'], float(withdrawal_possible)) 173 | self.assertEqual(self.metrics[f'eth_validator_status_scaled_count{{network="sepolia",scope="{label}",status="withdrawal_done"}}'], float(withdrawal_done)) 174 | 175 | test_for_label("scope:watched", active_ongoing=100) 176 | test_for_label("scope:all-network", active_ongoing=1785.09375, withdrawal_possible=150, withdrawal_done=0) 177 | test_for_label("scope:network", active_ongoing=1685.09375, withdrawal_possible=150, withdrawal_done=0) 178 | test_for_label("operator:kiln", active_ongoing=100) 179 | test_for_label("vc:prysm-validator-1", active_ongoing=50) 180 | test_for_label("vc:teku-validator-1", active_ongoing=50) 181 | 182 | @sepolia_test("config.sepolia_replay_2_slots.yaml") 183 | def test_sepolia_missed_attestation(self, slot: int): 184 | """Verifies attestation misses 185 | """ 186 | if slot != 7363592: 187 | return 188 | 189 | self.assertEqual(self.metrics['eth_missed_attestations{network="sepolia",scope="operator:kiln"}'], 0.0) 190 | self.assertEqual(self.metrics['eth_missed_attestations{network="sepolia",scope="vc:prysm-validator-1"}'], 0.0) 191 | self.assertEqual(self.metrics['eth_missed_attestations{network="sepolia",scope="vc:teku-validator-1"}'], 0.0) 192 | self.assertEqual(self.metrics['eth_missed_attestations{network="sepolia",scope="scope:watched"}'], 0.0) 193 | self.assertEqual(self.metrics['eth_missed_attestations{network="sepolia",scope="scope:all-network"}'], 101.0) 194 | self.assertEqual(self.metrics['eth_missed_attestations{network="sepolia",scope="scope:network"}'], 101.0) 195 | 196 | self.assertEqual(self.metrics['eth_missed_attestations_scaled{network="sepolia",scope="operator:kiln"}'], 0.0) 197 | self.assertEqual(self.metrics['eth_missed_attestations_scaled{network="sepolia",scope="vc:prysm-validator-1"}'], 0.0) 198 | self.assertEqual(self.metrics['eth_missed_attestations_scaled{network="sepolia",scope="vc:teku-validator-1"}'], 0.0) 199 | self.assertEqual(self.metrics['eth_missed_attestations_scaled{network="sepolia",scope="scope:watched"}'], 0.0) 200 | self.assertEqual(self.metrics['eth_missed_attestations_scaled{network="sepolia",scope="scope:all-network"}'], 100.9375) 201 | self.assertEqual(self.metrics['eth_missed_attestations_scaled{network="sepolia",scope="scope:network"}'], 100.9375) 202 | 203 | @sepolia_test("config.sepolia.yaml") 204 | def test_sepolia_missed_consecutive_attestation(self, slot: int): 205 | """Verifies consecutive attestation misses 206 | """ 207 | if slot != 7363686: 208 | return 209 | 210 | self.assertEqual(self.metrics['eth_missed_consecutive_attestations{network="sepolia",scope="operator:kiln"}'], 0.0) 211 | self.assertEqual(self.metrics['eth_missed_consecutive_attestations{network="sepolia",scope="vc:prysm-validator-1"}'], 0.0) 212 | self.assertEqual(self.metrics['eth_missed_consecutive_attestations{network="sepolia",scope="vc:teku-validator-1"}'], 0.0) 213 | self.assertEqual(self.metrics['eth_missed_consecutive_attestations{network="sepolia",scope="scope:watched"}'], 0.0) 214 | self.assertEqual(self.metrics['eth_missed_consecutive_attestations{network="sepolia",scope="scope:all-network"}'], 101.0) 215 | self.assertEqual(self.metrics['eth_missed_consecutive_attestations{network="sepolia",scope="scope:network"}'], 101.0) 216 | 217 | @sepolia_test("config.sepolia.yaml") 218 | def test_sepolia_blocks(self, slot: int): 219 | """Verifies block proposals and misses. 220 | """ 221 | if slot != 7363612: 222 | return 223 | 224 | self.assertEqual(self.metrics['eth_block_proposals_head_total{network="sepolia",scope="operator:kiln"}'], 2.0) 225 | self.assertEqual(self.metrics['eth_missed_block_proposals_head_total{network="sepolia",scope="operator:kiln"}'], 0.0) 226 | self.assertEqual(self.metrics['eth_block_proposals_finalized_total{network="sepolia",scope="operator:kiln"}'], 0.0) 227 | self.assertEqual(self.metrics['eth_missed_block_proposals_finalized_total{network="sepolia",scope="operator:kiln"}'], 0.0) 228 | 229 | self.assertEqual(self.metrics['eth_block_proposals_head_total{network="sepolia",scope="scope:all-network"}'], 18.0) 230 | self.assertEqual(self.metrics['eth_missed_block_proposals_head_total{network="sepolia",scope="scope:all-network"}'], 1.0) 231 | self.assertEqual(self.metrics['eth_block_proposals_finalized_total{network="sepolia",scope="scope:all-network"}'], 0.0) 232 | self.assertEqual(self.metrics['eth_missed_block_proposals_finalized_total{network="sepolia",scope="scope:all-network"}'], 0.0) 233 | 234 | @sepolia_test("config.sepolia.yaml") 235 | def test_sepolia_full(self, slot: int): 236 | """Runs a complete iteration of the watcher over two epochs. 237 | """ 238 | self.assertEqual(float(slot), self.metrics['eth_slot{network="sepolia"}']) 239 | --------------------------------------------------------------------------------