Skip to main content

Obol Setup

Obol provides permissionless access to distributed validators. A distributed validator (DV) is an Ethereum proof-of-stake validator that runs on more than one node. Each node is the set of clients an operator runs — execution and consensus layer clients, a validator client, and Charon, Obol's software that acts as middleware between the validator client and the consensus client.

The validator client talks to Charon instead of the beacon node. Charon runs a consensus algorithm letting the nodes agree on what data to sign (attestations and proposals). Each node's validator client signs that data with its key share, and Charon then aggregates a threshold of the nodes' partial signatures into the validator's final signature.

These key shares are created through a Distributed Key Generation (DKG) ceremony: instead of one party generating the full private key and splitting it among the nodes, every operator's Charon client takes part so the shares are generated together. The full private key is never constructed on any machine.

StakeWise Vault operators can use the Obol DVT to get the fault tolerance of distributed validators. The steps below cover both a solo operator running distributed validators across multiple machines, and a group of operators who each run their own node in the cluster.

Prerequisites

Before proceeding, ensure you have the following:

  1. Install Docker Engine ↗. Verify it is running:
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
  1. Create Vault
  2. Launch Operator Service
IconOperator Service Role

The Operator Service's role depends on how the validator keys are generated.

When the keys are generated centrally, the Operator Service can access them and registers the validators itself.

When the keys are generated through a DKG ceremony, no party ever holds the full key, so the Operator Service can't register on its own. Registration instead runs through the StakeWise DVT components (the Sidecars and the DVT Relayer) — see the registration process.

Create a DV Alone

It is possible for a single operator to manage all of the nodes of a DV cluster. The nodes can be run on a single machine, which is only suitable for testing, or the nodes can be run on multiple machines, which is expected for a production setup.

IconHardware Requirements

Each node runs an execution and a consensus client, which need to sync the chain — so the disk, RAM, and CPU add up quickly, even on a testnet. Check Obol's hardware specifications ↗ and size your machine(s) for the network you are validating.

Step 1: Create Cluster

The example below creates the key shares by splitting your existing validator keys, which requires the full keys to exist on your machine. Alternatively, the shares can be created in a lower-trust manner through a DKG ceremony, which avoids the validator private key being stored in full anywhere, at any point in its lifecycle. Follow Create a DV With a Group for that case instead.

You can also use Obol's DV Launchpad ↗ to configure your DV cluster. The Launchpad will give you a docker command to create your cluster.

  1. Create a .env file with Charon settings, with addresses in lowercase:
cat <<EOF > .env
export VAULT_CONTRACT_ADDR=[ENTER YOUR VAULT CONTRACT ADDRESS HERE]
export FEE_RECIPIENT_ADDR=[ENTER YOUR BLOCK REWARD RECIPIENT ADDRESS HERE]
export NETWORK=[ENTER YOUR NETWORK NAME]
EOF
  1. Split your validator keys across multiple nodes, replacing the example values for --nodes and --name:
source .env
docker run --rm -v "$(pwd):/opt/charon" -v "$HOME/.stakewise:/.stakewise" obolnetwork/charon:v1.10.2 \
create cluster \
--name="cluster-name" \
--cluster-dir=".charon/cluster/" \
--withdrawal-addresses=$VAULT_CONTRACT_ADDR \
--fee-recipient-addresses=$FEE_RECIPIENT_ADDR \
--nodes 3 \
--network $NETWORK \
--split-existing-keys \
--split-keys-dir /.stakewise/$VAULT_CONTRACT_ADDR/keystores
IconVerify Cluster Creation

Check that your cluster was created successfully:

ls -la .charon/cluster/

You should see node0/, node1/, and node2/ subdirectories.

IconCommand Flags Explained
FlagDescription
--nameThe cluster name
--cluster-dirTarget directory to write the cluster into; each node gets its own subfolder (default "./")
--withdrawal-addressesComma separated list of Ethereum addresses to receive returned stake and rewards. Provide single address or one per validator
--fee-recipient-addressesComma separated list of fee recipient addresses. Provide single address or one per validator. Must match the "Block reward recipient" address on your Vault page
--nodesNumber of Charon nodes in the cluster. Minimum is 3
--networkEthereum network. Options: mainnet, hoodi, gnosis (default "mainnet")
--split-existing-keysSplit existing validator private key into distributed key shares
--split-keys-dirDirectory containing keys to split. Expects keystore-*.json and keystore-*.txt. Requires --split-existing-keys

For the full list of optional flags, see the Charon CLI reference ↗

The docker command above produces a full set of distributed validators under .charon/cluster/, with one subdirectory per node. Each contains that node's private key share, a charon-enr-private-key (the node's networking identity), a cluster-lock.json that defines the cluster, and deposit-data.json used to activate the validators.

Example Output
***************** WARNING: Splitting keys **********************
Please make sure any existing validator has been shut down for
at least 2 finalised epochs before starting the charon cluster,
otherwise slashing could occur.
****************************************************************

Created charon cluster:
--split-existing-keys=true

/opt/charon/.charon/cluster/
├─ node[0-2]/ Directory for each node
│ ├─ charon-enr-private-key Charon networking private key for node authentication
│ ├─ cluster-lock.json Cluster lock file signed by all nodes
│ ├─ deposit-data.json Deposit data to activate a Distributed Validator
│ ├─ validator_keys Validator keystores and password
│ │ ├─ keystore-*.json Validator private share key for duty signing
│ │ ├─ keystore-*.txt Keystore password files for keystore-*.json
IconProtect Your Key Shares

Backup the .charon/cluster/ directory. Someone with access to these files could get the validators slashed.

Step 2: Run the Nodes

Running the whole cluster on one machine is suitable for testing only — if that machine fails, the cluster fails too.

  1. Copy the docker-compose.yml file below to the same directory as your .charon folder and update the --validators-proposer-default-fee-recipient flag for each validator client with your Block reward recipient address:
docker-compose.yml
x-logging: &logging
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
tag: "{{.ImageName}}|{{.Name}}|{{.ImageFullID}}|{{.FullID}}"

networks:
cluster:

# Base config shared by all Charon-based services (relay + nodes)
x-node-base:
# Pegged charon version (update this for each release).
&node-base
# Main Charon image (version can be overridden via CHARON_VERSION env var)
image: obolnetwork/charon:${CHARON_VERSION:-v1.10.2}
restart: unless-stopped
networks: [ cluster ]
depends_on: [ relay ]
volumes:
# Shared Charon config and keys directory
- ./.charon:/opt/charon/.charon/

# Common environment variables shared by all Charon nodes
x-node-env:
&node-env
# Consensus client (Beacon node) endpoint (Lighthouse in this stack)
CHARON_BEACON_NODE_ENDPOINTS: ${CHARON_BEACON_NODE_ENDPOINTS:-http://lighthouse:6000}
CHARON_LOG_LEVEL: ${CHARON_LOG_LEVEL:-info}
CHARON_LOG_FORMAT: ${CHARON_LOG_FORMAT:-console}
CHARON_BUILDER_API: true

# P2P configuration
CHARON_P2P_EXTERNAL_HOSTNAME: ${CHARON_P2P_EXTERNAL_HOSTNAME:-} # Empty default required to avoid warnings.
CHARON_P2P_RELAYS: ${CHARON_P2P_RELAYS:-http://relay:3640/enr}
CHARON_P2P_TCP_ADDRESS: ${CHARON_P2P_TCP_ADDRESS:-0.0.0.0:3610}

# Where the validator API is exposed for validator clients (Teku)
CHARON_VALIDATOR_API_ADDRESS: ${CHARON_VALIDATOR_API_ADDRESS:-0.0.0.0:3600}

services:
mev-boost:
image: flashbots/mev-boost:v1.10.1
restart: always
command: >
-mainnet
-relays
# List of mainnet MEV relays this instance will talk to
https://0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae@boost-relay.flashbots.net,https://0xa1559ace749633b997cb3fdacffb890aeebdb0f5a3b6aaa7eeeaf1a38af0a8fe88b9e4b1f61f236d2e64d95733327a62@relay.ultrasound.money,https://0x8b5d2e73e2a3a55c6c87b8b6eb92e0149a125c852751db1422fa951e42a09b82c142c3ea98d0d9930b056a3bc9896b8f@bloxroute.max-profit.blxrbdn.com,https://0xa7ab7a996c8584251c8f925da3170bdfd6ebc75d50f5ddc4050a6fdc77f2a3b5fce2cc750d0865e05d7228af97d69561@agnostic-relay.net,https://0xb0b07cd0abef743db4260b0ed50619cf6ad4d82064cb4fbec9d3ec530f7c5e6793d9f286c4e082c0244ffb9f2658fe88@bloxroute.regulated.blxrbdn.com,https://0xa15b52576bcbf1072f4a011c0f99f9fb6c66f3e1ff321f11f461d15e31b1cb359caa092c71bbded0bae5b5ea401aab7e@aestus.live
-addr
0.0.0.0:18551
-loglevel
info
-json
networks: [ cluster ]

geth:
image: ethereum/client-go:v1.16.7
restart: always
command: >
--mainnet
--syncmode=snap
--datadir=/data
--db.engine=pebble
--authrpc.jwtsecret=/data/jwtsecret --authrpc.addr=0.0.0.0 --authrpc.port=8551 --authrpc.vhosts=*
--http --http.addr=0.0.0.0 --http.port=8445 --http.corsdomain=* --http.vhosts=*
--port=30303
--ipcdisable
volumes: ["./data/geth:/data"]
ports:
- 30303:30303/tcp
- 30303:30303/udp
networks: [ cluster ]

lighthouse:
image: sigp/lighthouse:v8.1.3
restart: always
command: >
lighthouse
bn
--staking
--datadir=/data
--network=mainnet
--execution-endpoint=http://geth:8551
--execution-jwt=/data/jwtsecret
--checkpoint-sync-url=https://mainnet-checkpoint-sync.attestant.io/
--slots-per-restore-point=8192
--http
--http-port=6000
--http-address=0.0.0.0
--http-allow-origin=*
--builder http://mev-boost:18551
--port=30304
--enr-udp-port=30305
--disable-upnp
ulimits:
nofile:
soft: "1000000"
hard: "1000000"
volumes: ["./data/lighthouse:/data"]
ports:
- 30304:30304/tcp
- 30304:30304/udp
- 30305:30305/udp
networks: [ cluster ]

relay:
<<: *node-base
command: relay
depends_on: []
environment:
<<: *node-env
CHARON_HTTP_ADDRESS: 0.0.0.0:3640
CHARON_DATA_DIR: /opt/charon/relay
CHARON_P2P_RELAYS: ""
CHARON_P2P_EXTERNAL_HOSTNAME: relay
volumes:
- ./data/relay:/opt/charon/relay:rw

# CHARON NODES: 3-node distributed validator cluster (node0, node1, node2)
node0:
<<: *node-base
environment:
<<: *node-env
CHARON_PRIVATE_KEY_FILE: /opt/charon/.charon/cluster/node0/charon-enr-private-key
CHARON_LOCK_FILE: /opt/charon/.charon/cluster/node0/cluster-lock.json
CHARON_P2P_EXTERNAL_HOSTNAME: node0

node1:
<<: *node-base
environment:
<<: *node-env
CHARON_PRIVATE_KEY_FILE: /opt/charon/.charon/cluster/node1/charon-enr-private-key
CHARON_LOCK_FILE: /opt/charon/.charon/cluster/node1/cluster-lock.json
CHARON_P2P_EXTERNAL_HOSTNAME: node1

node2:
<<: *node-base
environment:
<<: *node-env
CHARON_PRIVATE_KEY_FILE: /opt/charon/.charon/cluster/node2/charon-enr-private-key
CHARON_LOCK_FILE: /opt/charon/.charon/cluster/node2/cluster-lock.json
CHARON_P2P_EXTERNAL_HOSTNAME: node2

# TEKU VALIDATOR CLIENTS: one VC per Charon node (vc0, vc1, vc2)
#
# Each Teku instance:
# - connects to its local Charon node’s validator API
# - uses validator key shares stored under .charon/cluster/nodeX/validator_keys
vc0-teku:
image: consensys/teku:${TEKU_VERSION:-25.11.0}
networks: [ cluster ]
depends_on: [ node0 ]
restart: unless-stopped
command: |
validator-client
--network=auto
--beacon-node-api-endpoint="http://node0:3600"
--validators-proposer-default-fee-recipient=[ENTER YOUR BLOCK REWARD RECIPIENT ADDRESS HERE]
--validators-builder-registration-default-enabled=true
--validator-keys="/opt/charon/validator_keys:/opt/charon/validator_keys"
--validators-keystore-locking-enabled=false
volumes:
- .charon/cluster/node0/validator_keys:/opt/charon/validator_keys
- ./data/vc0:/opt/charon/teku

vc1-teku:
image: consensys/teku:${TEKU_VERSION:-25.11.0}
networks: [ cluster ]
depends_on: [ node1 ]
restart: unless-stopped
command: |
validator-client
--network=auto
--beacon-node-api-endpoint="http://node1:3600"
--validators-proposer-default-fee-recipient=[ENTER YOUR BLOCK REWARD RECIPIENT ADDRESS HERE]
--validators-builder-registration-default-enabled=true
--validator-keys="/opt/charon/validator_keys:/opt/charon/validator_keys"
--validators-keystore-locking-enabled=false
volumes:
- .charon/cluster/node1/validator_keys:/opt/charon/validator_keys
- ./data/vc1:/opt/charon/teku

vc2-teku:
image: consensys/teku:${TEKU_VERSION:-25.11.0}
networks: [ cluster ]
depends_on: [ node2 ]
restart: unless-stopped
command: |
validator-client
--network=auto
--beacon-node-api-endpoint="http://node2:3600"
--validators-proposer-default-fee-recipient=[ENTER YOUR BLOCK REWARD RECIPIENT ADDRESS HERE]
--validators-builder-registration-default-enabled=true
--validator-keys="/opt/charon/validator_keys:/opt/charon/validator_keys"
--validators-keystore-locking-enabled=false
volumes:
- .charon/cluster/node2/validator_keys:/opt/charon/validator_keys
- ./data/vc2:/opt/charon/teku

The docker-compose.yml launches a full Obol/Charon distributed validator stack for Ethereum mainnet, consisting of Geth (execution client), Lighthouse (consensus client), mev-boost, Charon (relay + 3 nodes), and validator clients (3× Teku, one per Charon node: vc0/vc1/vc2). All services run on a shared cluster Docker network and are wired so that validator clients talk to Charon, and Charon talks to Lighthouse and Geth.

  1. Start the distributed validator cluster:
docker compose up -d

Create a DV With a Group

A DV cluster consists of multiple operators each provided with one of the M-of-N threshold BLS private key shares per validator. These shares are created together through a DKG ceremony.

Step 1: Create the cluster via DKG

The operators create the validator key shares together through a DKG ceremony, so the full key is never assembled on any machine. Follow Obol's Create a DV with a group ↗ guide — it walks each operator through generating an ENR, building the cluster-definition.json, running the ceremony, and starting their node.

IconStakeWise addresses

When creating the cluster definition, set the --withdrawal-addresses to your Vault contract address and the --fee-recipient-addresses to your Block reward recipient:

After the ceremony, each operator's .charon/ holds their validator_keys/ (key shares), cluster-lock.json, and deposit-data.json.

Step 2: Run the DVT Sidecar

Each operator runs one DVT Sidecar ↗ on the machine running their node. It loads the node's key shares, polls the Relayer for validator data, and submits deposit and exit signature shares back to the Relayer.

  1. Create the .env file from the repository's template:
cp .env.example .env
  1. Set the values for your Obol node:
# Network: mainnet or hoodi
NETWORK=mainnet

# DVT cluster type
CLUSTER_TYPE=OBOL

# URL of your DVT Relayer
RELAYER_ENDPOINT=http://relayer

# Directory with this node's key shares (keystore-*.json + keystore-*.txt)
OBOL_KEYSTORES_DIR=validator_keys
# The cluster-lock.json from the DKG ceremony
OBOL_CLUSTER_LOCK_FILE=cluster-lock.json
# This node's index in the cluster (0-based), used to select its share from the cluster lock
OBOL_NODE_INDEX=0

# Your execution and consensus client endpoints
EXECUTION_ENDPOINT=http://execution:8545
CONSENSUS_ENDPOINT=http://consensus:5052
  1. Run the container:
docker run \
-u $(id -u):$(id -g) \
--env-file .env \
-v $(pwd)/data:/data \
europe-west4-docker.pkg.dev/stakewiselabs/public/dvt-operator-sidecar:v2.0.0

Step 3: Run the DVT Relayer

One DVT Relayer ↗ serves the whole cluster. It collects the Sidecars' signature shares, reconstructs the full signatures, and serves the registration data to the Operator Service. It holds the Vault's Validators Manager wallet to sign registrations, but never has access to the validator keystores.

  1. Create the .env from the repository's template:
cp .env.example .env
  1. In a data/ directory, place the files the Relayer reads:

    • validators-manager-key.json and validators-manager-password.txt — the wallet set as your Vault's Validators Manager.
    • public_keys.txt — the cluster's validator public keys, one per line.
  2. Set the values (file paths point inside the mounted /data):

# API server
RELAYER_HOST=0.0.0.0
RELAYER_PORT=8000

# BLS signature threshold — must match your cluster's threshold
SIGNATURE_THRESHOLD=3

# Network: mainnet, hoodi, or gnosis
NETWORK=mainnet

# Execution and consensus client endpoints
EXECUTION_ENDPOINT=https://execution
CONSENSUS_ENDPOINT=https://consensus

# Validator public keys to register, one per line
PUBLIC_KEYS_FILE=/data/public_keys.txt

# The Validators Manager wallet that signs registrations
VALIDATORS_MANAGER_KEY_FILE=/data/validators-manager-key.json
VALIDATORS_MANAGER_PASSWORD_FILE=/data/validators-manager-password.txt
  1. Pull and run the Relayer:
export DVT_RELAYER_VERSION=v1.0.0
docker run --rm -ti \
--env-file .env \
-v $(pwd)/data:/data \
-p 8000:8000 \
europe-west4-docker.pkg.dev/stakewiselabs/public/dvt-relayer:$DVT_RELAYER_VERSION

Step 4: Start the Operator Service in Relayer mode

Because the Operator Service has no keystores, start it in Relayer mode pointing at your DVT Relayer:

./operator start-relayer

Obol's node setup (CDVN) ships a local Grafana dashboard. Once your nodes are running, check their health at http://localhost:3000/d/charonoverview/. For hosted dashboards and alerting, see Obol monitoring ↗.