Join our community of builders on Discord!

Become a LightChain Mainnet Validator

Production guide for running an external validator on the LightChain mainnet from a Linux server with a public IP.
Audience. This doc assumes a dedicated Linux VM or bare-metal box — the standard target for any serious validator. Running a real mainnet validator from a laptop is strongly discouraged — every sleep cycle costs missed attestations against real economic value.

Network parameters

Chain ID9200
Genesis fork version0x10000089
Deposit contract0x4242424242424242424242424242424242424242
Min deposit500k LCAI
Slot time6s
Slots per epoch6
Epoch length36s
Public RPC (read-only)https://rpc.mainnet.lightchain.ai
Public Beacon APIhttps://beacon.mainnet.lightchain.ai

Public peer endpoints

LightChain mainnet runs two public sentries. Use both for redundancy — single-sentry config works but loses peers entirely if that one sentry is down or rotates its keys. EL bootnodes (devp2p enodes):
CodeHTML
CL libp2p multiaddrs:
CodeHTML
EL pubkey rotation. A copy of the EL enode pubkey is also published at https://storage.googleapis.com/lightchain-mainnet-public/chain-config/enode-pubkey.txt, but at the time of writing that file is stale and will fail discv5 handshake — its pubkey starts 952786e9... while the live process advertises 79ea19ff.... The published file consistently lags the live process across chain resets. Use the inlined value above. If it also fails (e.g. after a chain reset), see Troubleshooting → "Geth has 0 peers".

Hardware

CPU2 vCPU
RAM8 GB
Disk200 GB NVMe SSD
Network25 Mbps up/down, low loss
Anything Hetzner / OVH / Latitude / Equinix would sell as a small dedicated server, or a 4-core cloud VM (AWS c6i.xlarge, GCP e2-standard-4, etc.) is plenty.

Server prerequisites

  • OS: Ubuntu 22.04 LTS or Debian 12 (other distros work; commands assume apt/ufw)
  • Docker Engine: install from docs.docker.com/engine/installnot Docker Desktop
  • Foundry: cast for the deposit transaction
  • Standard tools: jq, curl, openssl, git
CodeBASH

Networking

The server needs a public IP and the following ports open inbound:
PortProtoComponentPurpose
30303TCP+UDPgethdevp2p / discv5
13000TCPprysmlibp2p
12000UDPprysmdiscv5
CodeBASH
If you're behind a cloud-provider firewall (security group, VPC firewall), open the same ports there too. Discover and verify your public IP — you'll bake it into the node's P2P advertising:
CodeBASH

Genesis artifacts

Three files are needed in this working directory before launching the node:
  • genesis.json — EL (geth) genesis, used with geth init
  • genesis.ssz — CL (Prysm) genesis state, SSZ-serialized
  • config.yml — CL chain config (slot times, fork versions, deposit contract)
Fetch them from the public release bucket:
CodeBASH
Verify the artifacts are for mainnet (chain ID 9200, fork 0x10000089):
CodeBASH

Container images

Mainnet uses immutable commit-hash tags — there is no :latest. Pin the currently-deployed versions explicitly:
CodeHTML
These tags were verified deployed as of 2026-05-05. Check the registry for newer tags published with each chain release.

Run the node

Working directory

CodeBASH
Move the genesis files (genesis.json, genesis.ssz, config.yml) into this directory if you fetched them elsewhere.

One-time setup

Bundle the runtime env vars into a .env file so they survive new shell sessions — every section below depends on these:
CodeBASH
At the top of every new shell session for the rest of this guide: cd /opt/lightchain-validator && source .env
JWT shared between EL and CL:
CodeBASH
Verify $FEE_RECIPIENT is set before launching containers. Empty or malformed values cause Prysm to crash with FATAL --suggested-fee-recipient is not a valid Ethereum address and enter a restart loop.
CodeBASH

Initialize geth from genesis (one-time)

CodeBASH

Run geth (EL)

--network host gives geth direct access to the server's network stack — no Docker NAT, no port mapping, lower latency.
CodeBASH
Why these flags:
  • --authrpc.addr=127.0.0.1 — Engine API only reachable from the same host (the local prysm container)
  • --http.addr=127.0.0.1 — JSON-RPC only reachable locally; never expose this publicly without an auth proxy
  • --nat=extip:$PUBLIC_IP — geth advertises this IP to peers, so they can dial back

Run prysm beacon (CL)

Uses the public Beacon API for checkpoint sync — finalized state downloaded over HTTPS in seconds, then live P2P from genesis is unnecessary.
CodeBASH

Verify both clients are healthy

CodeBASH
Sync is complete when:
  • is_syncing: false and is_optimistic: false and el_offline: false
  • Local geth's block number matches the public RPC's
Typical time on a fresh server: ~5 minutes (checkpoint sync for CL is instant; geth catches up via P2P once peers are established).

Become a validator

Step 1 produces unrecoverable losses if done wrong — a deposit signed against the wrong fork version will be rejected by the LightChain consensus client and the funds will be permanently stuck in the deposit contract. Do not substitute tools without verifying the produced fork_version matches 0x10000089.

Step 1 — Generate validator keys

LightChain key generation uses protolambda/eth2-val-tools, which accepts the custom fork version and deposit amount that LightChain requires.
The widely-known staking-deposit-cli does not work for LightChain — its --chain parameter accepts only predefined networks (mainnet, sepolia, holesky, …) and hardcodes both the fork version and the 32 ETH deposit amount.
Set up directories and a wallet password:
CodeBASH
Generate a fresh mnemonic and stash it (the only way to recover withdrawal credentials):
CodeBASH
Set your withdrawal address (where the eventual exit balance gets paid — typically the EOA funding the deposit):
CodeBASH
Generate the EIP-2335 keystore in a Prysm-compatible wallet layout:
CodeBASH
Generate the deposit data, signed against LightChain mainnet's fork version, with a 500k LCAI amount and 0x01 (execution-layer) withdrawal credentials:
CodeBASH
Verify what the tool produced:
CodeBASH
You should see:
  • pubkey — your validator's BLS pubkey (48 bytes)
  • withdrawal_credentials0x01 + 11 zero bytes + your withdrawal address
  • value: 500000000000000 (= 500k LCAI in gwei)
  • signature and deposit_data_root
Back up these three files now, somewhere off this server:
  • validator-keys/mnemonic.txt
  • validator-keys/wallet-password.txt
  • validator-keys/deposit_data.json
The mnemonic is the only path to recover the validator. The wallet password unlocks the keystore Prysm uses to sign attestations.

Step 2 — Submit the deposit transaction

This sends 500k LCAI from your funder EOA to the deposit contract. It is irreversible — once the transaction lands, those funds are staked and can only be recovered via a validator exit.
CodeBASH
The funder EOA does not need to match the withdrawal address — they are independent. The funder pays gas + 500k LCAI; the withdrawal address is what's encoded in the deposit data signature. Sanity-check balance:
CodeBASH
Should be ≥ 500000 LCAI plus a small gas buffer. Send the deposit:
CodeBASH
Confirm status: 1 (success) in the receipt.

Step 3 — Run the validator client

CodeBASH
--chain-config-file is required. Without it, the validator client falls back to mainnet Ethereum defaults (12s slots, not LightChain's 6s) and computes the wrong current slot — you'll see Error getting validator duties: start slot N is smaller than the minimum valid start slot M and the validator never attests. The startup log line Running on custom Ethereum network specified in a chain configuration YAML file confirms the flag is honored.
Tail the logs:
CodeBASH
Expected sequence (pre-activation):
  1. Opened validator wallet keymanagerKind=direct
  2. Proposer settings loaded ... suggested-fee-recipient=0x...
  3. Initialized gRPC connection provider endpoints=[127.0.0.1:4000]
  4. Health status changed current=true previous=false url=127.0.0.1:4000
  5. Validator deposited, entering activation queue after finalization pubkey=0x... status=DEPOSITED
  6. No active validator keys provided. Waiting until next epoch to check again...
Line 6 reads alarming but is expected. Prysm's wording suggests the wallet is empty; what it actually means is "I have your key loaded, but it's not yet eligible to attest". The pairing of #5 (which prints your pubkey) and #6 in successive lines is the healthy pre-activation heartbeat. If your keys were genuinely missing, you'd see #6 alone with no Validator deposited ... pubkey=0x... above it.
The last line repeats every epoch (~36 s) until your validator becomes active_ongoing. Once it transitions, you'll see:
  1. Validator activated index=N pubkey=0x... status=ACTIVE
  2. Schedule for epoch X attesterCount=1 proposerCount=0
  3. Submitted new attestations slot=... (every epoch when assigned)

Step 4 — Watch for activation

You can query the public Beacon API directly:
CodeBASH
Or your local beacon (same data, fed by the same chain):
CodeBASH
Status progression:
CodeHTML
Realistic timing: 30–75 minutes from deposit transaction to active_ongoing, typically 45–65 min, dominated by EPOCHS_PER_ETH1_VOTING_PERIOD = 64 (≈ 38 min per voting period). The chain applies your deposit's eth1_data only at the end of a voting period with ≥50% supermajority, so the wait depends on where in the period the deposit lands:
  • Deposit lands early in a period → may catch the same period's vote → ~30–40 min
  • Deposit lands mid/late → misses the boundary → has to wait the full next period → ~60–75 min
After eth1_data is canonicalized, the next block proposer includes your deposit in block.body.deposits[], the validator joins state.validators[] with pending_initialized, and 1–2 epochs later it transitions through pending_queued to active_ongoing. Sanity check while waiting: if state.eth1_data.deposit_count is still the old value but state.eth1_data_votes shows entries for the new value, the chain is mid-voting-period and you're on track:
CodeBASH
Stale-period gotcha — don't panic if every vote is "0". During the first ~38 min after your deposit lands, you may observe eth1_data_votes accumulate entirely for deposit_count: "0" even though your deposit is already on the EL and cached by Prysm. This is the in-progress voting period that started before your deposit was visible to proposers — its votes were already locked in for the pre-deposit eth1 state. The pattern resolves at the next period boundary, when a fresh period starts and proposers begin voting for your deposit's count. Watch for the unique_votes set to flip from ["0"] to ["1"] (or higher); once ≥50% of slots in a period vote for the new count and the period boundary passes, eth1_data.deposit_count updates and your validator joins the registry within 1–2 epochs. Concretely, the worst-case path is: deposit lands mid-period → that period closes with all-zeros votes → next period opens and accumulates new-count votes → that period closes and applies the new eth1_data → 1–2 epochs to pending_initialized → 1–2 epochs to active_ongoing. End-to-end this can take up to ~80 min. Don't intervene unless that exceeds.
Once active_ongoing, the validator client log emits every epoch (~36 s):
CodeHTML
That last line is the gold standard — it confirms your attestation was included by a proposer and matched the canonical view.

Troubleshooting

Validator stuck at DEPOSITED for >1 hour

The chain processes deposits at the end of each eth1 voting period (EPOCHS_PER_ETH1_VOTING_PERIOD = 64, ≈ 38 min). One full period after the deposit lands, your validator should appear as pending_initialized and progress to active_ongoing within minutes. If after two voting periods (≈ 80 min) the public Beacon API still returns Unknown validator, open an issue with the deposit transaction hash and the output of the health checks below. Quick health check:
CodeBASH

Geth has 0 peers / Bad discv5 packet

First, verify your firewall actually opened 30303 TCP+UDP — ufw status verbose should list both. If both rules are present and your IP is reachable from the public internet, the bootnode enode pubkey may be stale (pubkeys can rotate after chain resets, and inlined values in this doc may briefly lag). Re-pull this doc to make sure the EL_BOOTNODES and CL_PEER_* values in your .env match the latest enodes / multiaddrs in the "Public peer endpoints" section above. If they differ, update .env, then recreate geth (chain data on disk is preserved — only the runtime flag changes):
CodeBASH
If the bootnode values in your .env already match the doc and peers are still 0 after 10+ minutes, open an issue with the output of:
CodeBASH

Validator log: start slot N is smaller than the minimum valid start slot M

Validator client is computing slots with the wrong SECONDS_PER_SLOT. Cause: missing --chain-config-file. Add --chain-config-file=/work/config.yml and recreate the container.

Validator log: FATAL --suggested-fee-recipient is not a valid Ethereum address

$FEE_RECIPIENT was empty or malformed when the container started. Set it to your funded EOA (full 0x + 40 hex chars) and recreate the container.

Beacon stays is_optimistic: true indefinitely

The CL is processing blocks but the EL hasn't validated their execution payloads. Geth is either still syncing or has lost peers.
CodeBASH
If the block number is climbing, just wait. If it's stuck at 0 with peer count 0, see "Geth has 0 peers" above.

Operations

Stop everything

CodeBASH
Data persists in ./geth/, ./beacon/, and ./validator-keys/. Restarting the containers picks up where they left off.

Tear down completely

CodeBASH
Keep validator-keys/mnemonic.txt somewhere safe before this — it's the only path to recover the validator after teardown if you didn't exit it.

Voluntary exit

Use wealdtech/ethdo to generate a signed SignedVoluntaryExit against the public beacon, then broadcast it via the Beacon REST API.
Prysm's own validator accounts voluntary-exit and prysmctl validator exit produce signatures with the wrong fork version on LightChain (the signing-domain code path silently uses mainnet Ethereum defaults regardless of --chain-config-file). The public beacon rejects with Invalid exit: signature did not verify. Until that's patched upstream, ethdo is the working path on this chain.
Prerequisite: validator must have been active_ongoing for at least SHARD_COMMITTEE_PERIOD (256) epochs (~2.5 hours at 36 s/epoch) before it's eligible to exit. Pre-eligible exits are accepted into the pool but never included.
  1. Generate the signed exit (uses your validator mnemonic, fetches chain config from the public beacon's /eth/v1/config/spec endpoint):
    CodeBASH
    Output is a SignedVoluntaryExit JSON object:
    CodeJSON
  2. Broadcast via the public beacon's REST API:
    CodeBASH
    Expected: HTTP 200 with empty body. (HTTP 400 with signature did not verify means ethdo couldn't reach a beacon configured with LightChain's chain config — verify the --connection URL.)
  3. Verify the exit was included in a block (within ~1 epoch):
    CodeBASH
    Status progression — each transition triggered by an epoch boundary:
    StatusWhen
    active_exitingExit included in a block (~1 min after broadcast); exit_epoch set
    exited_unslashedAt exit_epoch — validator stops attesting, balance frozen
    withdrawal_possibleAt exit_epoch + 256 (~2.5 h later at 36 s/epoch)
    withdrawal_doneAfter the next withdrawal sweep credits the 0x01 withdrawal address
  4. Predict the withdrawal time once status is active_exiting:
    CodeBASH
    withdrawable_epoch (= exit_epoch + 256) is when your stake becomes sweep-eligible. Multiply by 36 s/epoch and add the chain's genesis time to get the wall-clock timestamp. Query genesis time dynamically (don't hardcode — it changes on chain resets):
    CodeBASH
  5. Confirm the stake landed on the EL after status flips to withdrawal_done:
    CodeBASH
    The withdrawal address inherits the full 500,000 LCAI (less any prior partial-withdrawal sweeps if effective_balance ever exceeded MAX_EFFECTIVE_BALANCE, which on this chain is impossible because both equal 500k LCAI).

Operational notes during exit

  • Keep the validator client running between broadcast and exit_epoch. Your validator still has attestation duties through that window — stopping it early racks up missed attestations and inactivity penalties that reduce the exit balance.
  • The 256-epoch withdrawal delay is fixed by MIN_VALIDATOR_WITHDRAWABILITY_DELAY. There is no expedited path. Plan for ~2.5 h between exit_epoch and the EL credit landing.
  • Withdrawal sweep ordering is round-robin by validator index. With ~66 validators on the chain and MAX_WITHDRAWALS_PER_PAYLOAD = 16 per block, every validator gets visited within ~5 blocks of becoming withdrawable. Expect withdrawal_done within 30–60 s of withdrawable_epoch.
  • Once withdrawal_done, you can safely tear down the node — the validator role is finished. Use the "Tear down completely" recipe above.
The exit is irreversible once included in a block. After withdrawal_done, the full stake lands at the EL address encoded in your withdrawal credentials — no manual claim required for 0x01-prefix credentials.