Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Network configuration is controlled via `params.yaml`:
- `use_static_keys`: Use pre-computed keys (default: true, ~40s faster)
- `boole_epoch`: Boole fork activation epoch
- `network.network_params.fulu_fork_epoch`: Set >0 to test Electra→Fulu transition
- `network.network_params.gloas_fork_epoch`: Gloas (ePBS) activation; the sole Gloas switch (ePBS is gated on the Ethereum fork; no SSV-side fork knob). See `params-gloas.yaml`
- `register_validators`: Register keyshare validators on-chain (default false; aetheria registers its own)
- `monitor.enabled`: Enable monitoring stack
- `images.*`: Docker image overrides

Expand Down
23 changes: 21 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ ENCLAVE_NAME?=localnet
PARAMS_FILE?=params.yaml
SSV_NODE_COUNT?=4
SSV_COMMIT?=stage
ANCHOR_COMMIT?=unstable

default: run

Expand Down Expand Up @@ -78,13 +79,17 @@ prepare-ssv:
@echo "Building SSV image..."
@cd ../ssv && docker build -t node/ssv .

# prepare-anchor builds node/anchor at the FRESHEST commit for ANCHOR_COMMIT (branch, tag, or
# commit; default unstable), detaching like prepare-ssv. Note this moves ../anchor's checkout.
.PHONY: prepare-anchor
prepare-anchor:
@if [ ! -d "../anchor" ]; then \
echo "Cloning Anchor repo..." && \
git clone https://github.com/sigp/anchor.git ../anchor; \
fi
@cd ../anchor && git fetch origin && git checkout origin/unstable
@echo "Checking out Anchor $(ANCHOR_COMMIT) at its freshest commit..."
@cd ../anchor && git fetch origin --tags --force && \
( git checkout --detach "origin/$(ANCHOR_COMMIT)" 2>/dev/null || git checkout --detach "$(ANCHOR_COMMIT)" )
@echo "Building Anchor image..."
@cd ../anchor && docker build -f Dockerfile.devnet -t node/anchor .

Expand Down Expand Up @@ -185,10 +190,13 @@ help:
@echo "Network scenarios:"
@echo " make run Default: Fulu (all forks active)"
@echo " make run-boole Boole fork at epoch 3, Fulu at epoch 5"
@echo " make run-gloas Gloas (ePBS) at genesis, all-Anchor cluster"
@echo " make run-gloas-builders Gloas at genesis + buildoor ePBS builders"
@echo " make run PARAMS_FILE=custom.yaml Custom params"
@echo ""
@echo "Configuration:"
@echo " SSV_COMMIT=main make prepare Use a specific SSV branch"
@echo " SSV_COMMIT=main make prepare Use a specific SSV branch"
@echo " ANCHOR_COMMIT=epbs make prepare-anchor Use a specific Anchor branch"
@echo ""
@echo "Static keys:"
@echo " make generate-keys Regenerate static operator keys + keyshares"
Expand All @@ -203,6 +211,17 @@ run-boole: ensure-keys
@echo "──── Starting SSV testnet (Boole fork at epoch 3) ────"
kurtosis run --enclave $(ENCLAVE_NAME) --args-file params-boole.yaml .

# Gloas (ePBS) profiles need a Gloas-capable Anchor image: ANCHOR_COMMIT=epbs make prepare-anchor
.PHONY: run-gloas
run-gloas: ensure-keys
@echo "──── Starting SSV testnet (Gloas/ePBS at genesis, all-Anchor cluster) ────"
kurtosis run --enclave $(ENCLAVE_NAME) --args-file params-gloas.yaml .

.PHONY: run-gloas-builders
run-gloas-builders: ensure-keys
@echo "──── Starting SSV testnet (Gloas/ePBS at genesis + buildoor builders) ────"
kurtosis run --enclave $(ENCLAVE_NAME) --args-file params-gloas-builders.yaml .

# ── Tests ────────────────────────────────────────────────────────────

.PHONY: test-faulty-el
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,54 @@ ssv-mini restart # Rebuild + restart SSV nodes only
ssv-mini logs # Tail SSV node 0 logs
```

## Gloas (ePBS) testnet

Run an all-Anchor cluster on a Gloas / EIP-7732 (ePBS) chain, for testing Anchor's `epbs` branch
through block proposal and the new 3s attestation deadline. Uses nethermind + lighthouse
`glamsterdam-devnet-5` images (geth has no Glamsterdam build).

```bash
ANCHOR_COMMIT=epbs make prepare-anchor # build node/anchor from the epbs branch (Rust build, slow)
make run-gloas # or: make run-gloas-builders
make logs SERVICE=anchor-node-0 # tail an operator (no ssv-node-0 in these profiles)
```

Two profiles:
- **`params-gloas.yaml`** (`make run-gloas`): Gloas at genesis, 4-operator all-Anchor cluster,
keyshare validators registered on-chain. The core profile for proposal tests (attestation timing
needs an anchor image carrying the #1061 fix; see below).
- **`params-gloas-builders.yaml`** (`make run-gloas-builders`): adds 2 genesis ePBS builders plus
buildoor, exercising external bids, payload reveals, and chain-level PTC (`payload_attestations`).

### Notes and edge cases

- **Use a recent Kurtosis CLI.** 1.15.x fails on a `GpuConfig` builtin in the pinned
`ethereum-package`; 1.19.x works (`brew upgrade kurtosis-tech/tap/kurtosis-cli && kurtosis engine restart`).
- **The profiles import a forked `ethereum-package` pin** (`shane-moore/ethereum-package`, by commit).
Kurtosis fetches it automatically, no action needed. The fork exists because Gloas genesis needs
`ethereum-genesis-generator >= 6.0.0` (no tagged release ships it), plus a one-line fix so genesis
ePBS builders do not collide with the preregistered keyshare validators (indices 64-73).
- **Gloas is driven by `gloas_fork_epoch`; SSV attestations need the #1061 fix in your anchor image.**
ePBS activates purely from the Ethereum Gloas fork; there is no SSV-side fork knob. Block proposals, external
builder bids, and chain-level PTC work whenever Gloas is active. SSV cluster attestations also need
an anchor image carrying [sigp/anchor#1061](https://github.com/sigp/anchor/issues/1061): with Gloas
active the committee runs `GloasBeaconVote` for attestations, but the sync-committee path still runs
`BeaconVote`, so the two no longer share one committee QBFT instance. The attestation consensus still
reaches COMMIT, but the sync consensus round-changes every slot and never completes, and because the
committee's post-consensus partial-signature batch is sized to attestations + sync messages, it never
fills, so the agreed attestation is never submitted. Measured live on an `epbs` image without #1061:
proposals canonical, attestations 0. Consensus succeeds; only submission is blocked.
- **`make prepare-anchor` moves `../anchor`'s checkout** to a detached HEAD at `origin/epbs`. If you
keep local work in `../anchor`, branch or stash it first (commits are not lost, but HEAD relocates).
- **All-Anchor cluster** (`ssv.count: 0`): no `node/ssv` image is needed, so skip `make prepare`.
Monitor is disabled in these profiles and the cluster does not need it; leave it off.
- **Builders live in the Gloas `BeaconState.builders` registry**, not the validator set. Query there,
not `/eth/v1/beacon/states/head/validators`.
- **Cluster slots are execution-empty by design (for now).** Anchor proposers self-build and never
reveal the payload (SIP-94 envelope stub), so cluster-proposed slots advance the CL but not the EL,
and PTC votes `payload_present=false` on them. Expected, not a failure: the gating work is Anchor's
bid-targeting path, not envelope signing.

## All Commands

```
Expand All @@ -55,6 +103,8 @@ make help
|---------|-------------|
| `make run` | Start testnet (default: Fulu, all forks active) |
| `make run-boole` | Start with Boole fork at epoch 3, Fulu at epoch 5 |
| `make run-gloas` | Start with Gloas (ePBS) at genesis, all-Anchor cluster |
| `make run-gloas-builders` | Start with Gloas at genesis + buildoor ePBS builders |
| `make reset` | Clean + restart from genesis |
| `make show` | Show running services and ports |
| `make logs` | Tail ssv-node-0 logs (`SERVICE=ssv-node-1` for others) |
Expand Down Expand Up @@ -94,11 +144,17 @@ network:
boole_epoch: 3 # Omit for pre-Boole

use_static_keys: true # false = regenerate keys at runtime (~40s slower)

register_validators: true # Register keyshare validators on-chain (default false; the
# aetheria executor registers its own). Required for standalone
# runs where SSV/Anchor nodes should actually perform duties.
```

Pre-built configs:
- `params.yaml` — Fulu at genesis (default)
- `params-boole.yaml` — Electra→Boole→Fulu fork transitions
- `params-gloas.yaml` — Gloas (ePBS) at genesis, all-Anchor cluster (needs `ANCHOR_COMMIT=epbs make prepare-anchor`)
- `params-gloas-builders.yaml` — Gloas at genesis + buildoor ePBS builders (experimental)

```bash
make run PARAMS_FILE=params-boole.yaml
Expand Down
6 changes: 3 additions & 3 deletions contract/interactions.star
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ def register_operators(plan, public_keys, network_address):
# register_validators bulk-registers the keyshare validators on-chain via the ethers script
# (register-validators.cjs) in a node-based service with the keyshares mounted.
#
# UNUSED: validator pre-registration is skipped on local_testnet (main.star Step 4) — the aetheria
# executor registers and funds its own validators. Retained (with register-validators.cjs) pending
# devnet pre-registration, tracked under #29.
# Gated behind `register_validators` in params (main.star Step 4, default off): the aetheria
# local_testnet flow skips it (the executor registers and funds its own validators; see #29),
# while standalone runs (e.g. the Gloas/ePBS profiles) enable it so duties fire.
def register_validators(plan, keyshare_artifact, network_address, token_address, rpc, genesis_constants, args):
plan.add_service(
name="register-validator",
Expand Down
9 changes: 7 additions & 2 deletions contract/registration/register-operators.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const OUT_FILE = process.env.OPERATOR_DATA_FILE || "/app/operator_data.json";

const OPERATOR_FEE = 1000000000n; // 1 SSV (smallest unit)
const MAX_OPERATOR_FEE = 76528650000000n;
// Explicit gas limit: nethermind's eth_estimateGas underestimates SSVNetwork's
// delegatecall-heavy paths (observed: 160k estimate vs 494k actual on registerOperator,
// inner delegatecall OOG via the 63/64 rule -> revert). ethers v6 sends the raw estimate
// as gasLimit, so override it. Harmless on geth.
const GAS_LIMIT = 1500000n;

async function main() {
const abi = JSON.parse(fs.readFileSync("/app/abis/SSVNetwork.json", "utf8"));
Expand All @@ -23,13 +28,13 @@ async function main() {
const publicKeys = JSON.parse(fs.readFileSync(KEYS_FILE, "utf8")).publicKeys;
const coder = ethers.AbiCoder.defaultAbiCoder();

await (await ssv.updateMaximumOperatorFee(MAX_OPERATOR_FEE)).wait();
await (await ssv.updateMaximumOperatorFee(MAX_OPERATOR_FEE, { gasLimit: GAS_LIMIT })).wait();

const operators = [];
for (const pk of publicKeys) {
// The operator public key (RSA PEM string) is ABI-encoded as `bytes`, matching SSV tooling.
const encoded = coder.encode(["string"], [pk]);
const receipt = await (await ssv.registerOperator(encoded, OPERATOR_FEE, false)).wait();
const receipt = await (await ssv.registerOperator(encoded, OPERATOR_FEE, false, { gasLimit: GAS_LIMIT })).wait();
let id;
for (const log of receipt.logs) {
try {
Expand Down
16 changes: 13 additions & 3 deletions contract/registration/register-validators.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Registers SSV validators on the (v2.0.0) SSVNetwork via ethers — replaces the foundry
// RegisterValidators.s.sol. Reads keyshares (shares[].payload.{publicKey,sharesData,operatorIds}),
// approves the SSV token, and bulk-registers them into a fresh cluster.
// Note: v2.0.0's bulkRegisterValidator dropped the `amount` param (4 args, not 5).
// Note: v2.0.0's bulkRegisterValidator dropped the `amount` param (4 args, not 5) and is
// PAYABLE: the cluster is funded with native ETH via msg.value, and a zero-value call
// reverts with InsufficientBalance() (0.01 ETH suffices on the local net; we send 1).
const fs = require("fs");
const { ethers } = require("ethers");

Expand All @@ -11,6 +13,11 @@ const NETWORK_ADDR = process.env.SSV_NETWORK_ADDRESS;
const TOKEN_ADDR = process.env.SSV_TOKEN_ADDRESS;
const KEYSHARES_FILE = process.env.KEYSHARES_FILE || "/app/keyshares/out.json";

// Explicit gas limit: nethermind's eth_estimateGas underestimates SSVNetwork's
// delegatecall-heavy paths (see register-operators.cjs), and bulkRegisterValidator
// carries ~10 validators of sharesData. ethers v6 sends the raw estimate as gasLimit.
const GAS_LIMIT = 10000000n;

async function main() {
const abi = JSON.parse(fs.readFileSync("/app/abis/SSVNetwork.json", "utf8"));
const provider = new ethers.JsonRpcProvider(RPC);
Expand All @@ -25,8 +32,11 @@ async function main() {
// Fresh cluster (never registered for this owner+operators).
const cluster = { validatorCount: 0, networkFeeIndex: 0, index: 0, active: true, balance: 0 };

await (await token.approve(NETWORK_ADDR, ethers.parseEther("1"))).wait();
await (await ssv.bulkRegisterValidator(publicKeys, operatorIds, sharesData, cluster)).wait();
await (await token.approve(NETWORK_ADDR, ethers.parseEther("1"), { gasLimit: 200000n })).wait();
await (await ssv.bulkRegisterValidator(publicKeys, operatorIds, sharesData, cluster, {
gasLimit: GAS_LIMIT,
value: ethers.parseEther("1"),
})).wait();
console.log("Registered " + publicKeys.length + " validator(s)");
}

Expand Down
35 changes: 27 additions & 8 deletions main.star
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
ethereum_package = import_module("github.com/ethpandaops/ethereum-package/main.star@6.1.0")
input_parser = import_module("github.com/ethpandaops/ethereum-package/src/package_io/input_parser.star@6.1.0")
genesis_constants = import_module("github.com/ethpandaops/ethereum-package/src/prelaunch_data_generator/genesis_constants/genesis_constants.star@6.1.0")
# Pinned to a fork of ethereum-package main @ d47e987 (2026-06-11) plus one fix, branch
# shane-moore/ethereum-package@ssv-mini/builder-skip-preregistered. Why not a release: Gloas
# (ePBS) genesis needs ethereum-genesis-generator >= 6.0.0 and the latest tagged release
# (6.1.0) still pins 5.3.5. Why the fork: upstream's builder_count derives genesis builders
# (and buildoor's key) starting at the participant validator sum, colliding with our
# preregistered keyshare validators 64-73; the fix skips past preregistered_validator_count.
ethereum_package = import_module("github.com/shane-moore/ethereum-package/main.star@24e306ff4cd575417b9163d8e6738d740d0c133a")
input_parser = import_module("github.com/shane-moore/ethereum-package/src/package_io/input_parser.star@24e306ff4cd575417b9163d8e6738d740d0c133a")
genesis_constants = import_module("github.com/shane-moore/ethereum-package/src/prelaunch_data_generator/genesis_constants/genesis_constants.star@24e306ff4cd575417b9163d8e6738d740d0c133a")
ssv_node = import_module("./nodes/ssv/node.star")
anchor_node = import_module("./nodes/anchor/node.star")
blocks = import_module("./blockchain/blocks.star")
Expand Down Expand Up @@ -118,11 +124,24 @@ def run(plan, args):
plan.remove_service(constants.ANCHOR_KEYSPLIT_SERVICE, description="Cleaning up keysplit service")

# ── Step 4: Register validators on-chain ──
# Skipped on the v2.0.0 contracts: the aetheria executor registers and funds its own validators
# (registration is payable/msg.value on v2.0.0), and its event flow expects a clean, empty
# cluster. Devnet pre-registration of the static keyshares (the payable-deposit path) is tracked
# in ssvlabs/ssv-mini#29.
plan.print("Step 4/5: Skipping validator pre-registration (executor registers its own; see #29)")
# Default off: the aetheria executor registers and funds its own validators (registration is
# payable/msg.value on v2.0.0), and its event flow expects a clean, empty cluster (see #29).
# Standalone runs (no aetheria, e.g. the Gloas/ePBS profiles) set register_validators: true so
# the keyshare validators are registered to the operator cluster and duties actually fire.
if args.get("register_validators", False):
plan.print("Step 4/5: Registering keyshare validators on-chain")
interactions.register_validators(
plan,
keyshare_artifact,
constants.SSV_NETWORK_PROXY_CONTRACT,
constants.SSV_TOKEN_CONTRACT,
el_rpc,
genesis_constants,
args,
)
plan.remove_service("register-validator", description="Cleaning up validator registration service")
else:
plan.print("Step 4/5: Skipping validator pre-registration (executor registers its own; see #29)")

# ── Step 5: Start SSV and Anchor nodes ──
node_index = 0
Expand Down
8 changes: 8 additions & 0 deletions nodes/anchor/node.star
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@ def start(plan, num_nodes, cl_url, el_rpc, el_ws, key_pems, config, image):
"--logfile-max-number", "0", "--debug-level", "debug",
# mitigation of https://github.com/sigp/anchor/issues/765
"--subscribe-all-subnets",
# Prometheus metrics (duty timing, head-event trigger counters, QBFT instrumentation)
"--metrics", "--metrics-address", "0.0.0.0", "--metrics-port", "5164",
]

metrics_ports = {
"metrics": PortSpec(number=5164, transport_protocol="TCP", application_protocol="http"),
}

plan.add_service(
name="anchor-node-0",
description="Starting Anchor bootnode (node 0)",
Expand All @@ -25,6 +31,7 @@ def start(plan, num_nodes, cl_url, el_rpc, el_ws, key_pems, config, image):
entrypoint=["anchor"],
cmd=command_arr,
files=files,
ports=metrics_ports,
private_ip_address_placeholder=IP_PLACEHOLDER,
ready_conditions=ReadyCondition(
recipe=ExecRecipe(
Expand Down Expand Up @@ -54,6 +61,7 @@ def start(plan, num_nodes, cl_url, el_rpc, el_ws, key_pems, config, image):
entrypoint=["anchor"],
cmd=command_arr_with_boot,
files=files,
ports=metrics_ports,
private_ip_address_placeholder=IP_PLACEHOLDER,
)
plan.add_services(remaining_configs, description="Starting {} remaining Anchor nodes in parallel".format(num_nodes - 1))
Expand Down
Loading