Skip to content
Open
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
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,76 @@ Ansible mode is ideal for:
- Multi-host deployments
- Infrastructure as Code workflows

## Shadow Network Simulator

[Shadow](https://shadow.github.io/) is a discrete-event network simulator that runs real application binaries over a simulated network. It is useful for reproducible multi-node testing without real hardware or cloud resources.

### Requirements

- **Shadow** (v3.x): [Install guide](https://shadow.github.io/docs/guide/install.html)
- **yq**: YAML processor (same as regular devnet)
- **Docker**: Required for genesis generation

### Quick Start

From the client repo root (with lean-quickstart as a submodule):

```sh
cd lean-quickstart
./run-shadow.sh
```

This single command:
1. Generates genesis with a fixed Shadow-compatible timestamp (`946684860`)
2. Generates `shadow.yaml` from `shadow-devnet/genesis/validator-config.yaml`
3. Runs Shadow simulation (default: 360 seconds)

### Options

```sh
# Custom simulation time
./run-shadow.sh --stop-time 600s

# Force regenerate hash-sig keys
./run-shadow.sh --forceKeyGen

# Use a different genesis directory
./run-shadow.sh --genesis-dir /path/to/custom/genesis
```

### Configuration

Shadow devnet configuration lives in `shadow-devnet/genesis/validator-config.yaml`. It uses virtual IPs (`100.0.0.x`) required by Shadow's simulated network:

```yaml
validators:
- name: "zeam_0"
enrFields:
ip: "100.0.0.1"
quic: 9001
# ...
```

To test a different client, change the node name prefixes (e.g., `ream_0`) and ensure a matching `client-cmds/<client>-cmd.sh` exists.

### How It Works

- **`run-shadow.sh`** — orchestrator: genesis → shadow.yaml → `shadow` execution
- **`generate-shadow-yaml.sh`** — reads `validator-config.yaml`, sources each client's `client-cmds/<client>-cmd.sh` to build per-node command lines, and emits `shadow.yaml`
- **`generate-genesis.sh --genesis-time 946684860`** — produces genesis with Shadow's virtual clock epoch (Jan 1, 2000 + 60s warmup)

Shadow's virtual clock starts at Unix timestamp `946684800` (Jan 1, 2000). Genesis time is set to `946684860` (epoch + 60s) to give nodes time to initialize.

### Checking Results

```sh
# Check consensus status
grep 'new_head\|finalized' shadow.data/hosts/*/*.stderr | tail -20

# Full node logs
cat shadow.data/hosts/zeam-0/zeam.1000.stderr
```

## Client branches

Clients can maintain their own branches to integrated and use binay with their repos as the static targets (check `git diff main zeam_repo`, it has two nodes, both specified to run `zeam` for sim testing in zeam using the quickstart generated genesis).
Expand Down
49 changes: 36 additions & 13 deletions generate-genesis.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ PK_DOCKER_IMAGE="ethpandaops/eth-beacon-genesis:pk910-leanchain"
# ========================================
show_usage() {
cat << EOF
Usage: $0 <genesis-directory> [--mode local|ansible] [--offset <seconds>] [--forceKeyGen]
Usage: $0 <genesis-directory> [--mode local|ansible] [--offset <seconds>] [--genesis-time <timestamp>] [--forceKeyGen]

Generate genesis configuration files using PK's eth-beacon-genesis tool.
Generates: config.yaml, validators.yaml, nodes.yaml, genesis.json, genesis.ssz, and .key files
Expand All @@ -30,12 +30,15 @@ Options:
- local: GENESIS_TIME = now + 30 seconds (default)
- ansible: GENESIS_TIME = now + 360 seconds (default)
--offset <seconds> Override genesis time offset in seconds (overrides mode defaults)
--genesis-time <ts> Use exact genesis timestamp (unix seconds). Overrides --mode and --offset.
Useful for Shadow simulator (e.g., 946684860) or replay scenarios.
--forceKeyGen Force regeneration of hash-sig validator keys

Examples:
$0 local-devnet/genesis # Local mode (30s offset)
$0 ansible-devnet/genesis --mode ansible # Ansible mode (360s offset)
$0 ansible-devnet/genesis --mode ansible --offset 600 # Custom 600s offset
$0 shadow-devnet/genesis --genesis-time 946684860 # Shadow simulator (fixed epoch)

Generated Files:
- config.yaml Auto-generated with GENESIS_TIME, VALIDATOR_COUNT, shuffle, and config.activeEpoch
Expand Down Expand Up @@ -89,6 +92,7 @@ VALIDATOR_CONFIG_FILE="$GENESIS_DIR/validator-config.yaml"
SKIP_KEY_GEN="true"
DEPLOYMENT_MODE="local" # Default to local mode
GENESIS_TIME_OFFSET="" # Will be set based on mode or --offset flag
EXACT_GENESIS_TIME="" # If set, use this exact timestamp (ignores mode/offset)
shift
while [[ $# -gt 0 ]]; do
case "$1" in
Expand Down Expand Up @@ -118,6 +122,19 @@ while [[ $# -gt 0 ]]; do
exit 1
fi
;;
--genesis-time)
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then
if ! [[ "$2" =~ ^[0-9]+$ ]]; then
echo "❌ Error: --genesis-time requires a positive integer (unix timestamp)"
exit 1
fi
EXACT_GENESIS_TIME="$2"
shift 2
else
echo "❌ Error: --genesis-time requires a value (unix timestamp)"
exit 1
fi
;;
*)
shift
;;
Expand Down Expand Up @@ -338,21 +355,27 @@ echo ""
# ========================================
echo "🔧 Step 2: Generating config.yaml..."

# Calculate genesis time based on deployment mode or explicit offset
# Calculate genesis time based on deployment mode, explicit offset, or exact timestamp
# Default offsets: Local mode: 30 seconds, Ansible mode: 360 seconds
TIME_NOW="$(date +%s)"
if [ -n "$GENESIS_TIME_OFFSET" ]; then
# Use explicit offset if provided
:
elif [ "$DEPLOYMENT_MODE" == "local" ]; then
GENESIS_TIME_OFFSET=30
if [ -n "$EXACT_GENESIS_TIME" ]; then
# Use exact genesis time (e.g., for Shadow simulator)
GENESIS_TIME="$EXACT_GENESIS_TIME"
echo " Using exact genesis time: $GENESIS_TIME"
else
GENESIS_TIME_OFFSET=360
TIME_NOW="$(date +%s)"
if [ -n "$GENESIS_TIME_OFFSET" ]; then
# Use explicit offset if provided
:
elif [ "$DEPLOYMENT_MODE" == "local" ]; then
GENESIS_TIME_OFFSET=30
else
GENESIS_TIME_OFFSET=360
fi
GENESIS_TIME=$((TIME_NOW + GENESIS_TIME_OFFSET))
echo " Deployment mode: $DEPLOYMENT_MODE"
echo " Genesis time offset: ${GENESIS_TIME_OFFSET}s"
echo " Genesis time: $GENESIS_TIME"
fi
GENESIS_TIME=$((TIME_NOW + GENESIS_TIME_OFFSET))
echo " Deployment mode: $DEPLOYMENT_MODE"
echo " Genesis time offset: ${GENESIS_TIME_OFFSET}s"
echo " Genesis time: $GENESIS_TIME"

# Sum all individual validator counts from validator-config.yaml
TOTAL_VALIDATORS=$(yq eval '.validators[].count' "$VALIDATOR_CONFIG_FILE" | awk '{sum+=$1} END {print sum}')
Expand Down
207 changes: 207 additions & 0 deletions generate-shadow-yaml.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
#!/bin/bash
set -e

# generate-shadow-yaml.sh — Generate shadow.yaml from validator-config.yaml
#
# Multi-client: reuses existing client-cmds/<client>-cmd.sh to get node_binary.
# Works for zeam, ream, lantern, gean, or any client with a *-cmd.sh file.
#
# Usage:
# ./generate-shadow-yaml.sh <genesis-dir> --project-root <path> [--stop-time 360s] [--output shadow.yaml]

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

show_usage() {
cat << EOF
Usage: $0 <genesis-dir> --project-root <path> [--stop-time 360s] [--output shadow.yaml]

Generate a Shadow network simulator configuration (shadow.yaml) from validator-config.yaml.

Arguments:
genesis-dir Path to genesis directory containing validator-config.yaml

Options:
--project-root <path> Project root directory (parent of lean-quickstart). Required.
--stop-time <time> Shadow simulation stop time (default: 360s)
--output <path> Output shadow.yaml path (default: <project-root>/shadow.yaml)

This script is client-agnostic. It reads node names from validator-config.yaml,
extracts the client name from the node prefix (e.g., zeam_0 → zeam), and sources
the corresponding client-cmds/<client>-cmd.sh to generate per-node arguments.
EOF
exit 1
}

# ========================================
# Parse arguments
# ========================================
if [ -z "$1" ] || [ "${1:0:1}" == "-" ]; then
show_usage
fi

GENESIS_DIR="$(cd "$1" && pwd)"
shift

PROJECT_ROOT=""
STOP_TIME="360s"
OUTPUT_FILE=""

while [[ $# -gt 0 ]]; do
case "$1" in
--project-root)
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then
PROJECT_ROOT="$(cd "$2" && pwd)"
shift 2
else
echo "❌ Error: --project-root requires a path"
exit 1
fi
;;
--stop-time)
if [ -n "$2" ]; then
STOP_TIME="$2"
shift 2
else
echo "❌ Error: --stop-time requires a value"
exit 1
fi
;;
--output)
if [ -n "$2" ]; then
OUTPUT_FILE="$2"
shift 2
else
echo "❌ Error: --output requires a path"
exit 1
fi
;;
*)
echo "❌ Unknown option: $1"
show_usage
;;
esac
done

if [ -z "$PROJECT_ROOT" ]; then
echo "❌ Error: --project-root is required"
show_usage
fi

if [ -z "$OUTPUT_FILE" ]; then
OUTPUT_FILE="$PROJECT_ROOT/shadow.yaml"
fi

VALIDATOR_CONFIG="$GENESIS_DIR/validator-config.yaml"
if [ ! -f "$VALIDATOR_CONFIG" ]; then
echo "❌ Error: validator-config.yaml not found at $VALIDATOR_CONFIG"
exit 1
fi

# ========================================
# Read nodes from validator-config.yaml
# ========================================
node_names=($(yq eval '.validators[].name' "$VALIDATOR_CONFIG"))
node_count=${#node_names[@]}
Comment on lines +100 to +104
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yq is used to read validator names before any dependency check, so if yq is missing this script will fail with a generic "command not found". Add an explicit command -v yq check (similar to parse-vc.sh) near the top and exit with a clear install hint.

Copilot uses AI. Check for mistakes.

if [ "$node_count" -eq 0 ]; then
echo "❌ Error: No validators found in $VALIDATOR_CONFIG"
exit 1
fi

echo "🔧 Generating shadow.yaml for $node_count nodes..."

# ========================================
# Write shadow.yaml preamble
# ========================================
cat > "$OUTPUT_FILE" << EOF
# Auto-generated Shadow network simulator configuration
# Generated from: $VALIDATOR_CONFIG
# Nodes: ${node_names[*]}

general:
model_unblocked_syscall_latency: true
stop_time: $STOP_TIME

experimental:
native_preemption_enabled: true

network:
graph:
type: 1_gbit_switch

hosts:
EOF

# ========================================
# Generate per-node host entries
# ========================================
for i in "${!node_names[@]}"; do
item="${node_names[$i]}"

# Extract client name from node prefix (zeam_0 → zeam, leanspec_0 → leanspec)
IFS='_' read -r -a elements <<< "$item"
client="${elements[0]}"

Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The client value is derived from validator-config.yaml and then interpolated into a path that is sourced. If a user points --genesis-dir at an untrusted config, a crafted validator name could trigger path traversal (e.g., ../../...) and execute arbitrary code. Restrict client to a safe allowlist pattern (e.g., [a-z0-9-]+), reject values containing / or .., and fail fast with an error before sourcing.

Suggested change
# Validate client name to prevent path traversal and restrict characters
# Allowed: lowercase letters, digits, and hyphens. Disallow '/' and '..'.
if [[ "$client" == *"/"* || "$client" == *".."* || ! "$client" =~ ^[a-z0-9-]+$ ]]; then
echo "❌ Error: Invalid client name '$client'. Allowed pattern: [a-z0-9-]+ and no '/' or '..'."
exit 1
fi

Copilot uses AI. Check for mistakes.
# DNS-valid hostname: underscores → hyphens (Shadow requirement)
hostname="${item//_/-}"

# Extract IP from validator-config
ip=$(yq eval ".validators[$i].enrFields.ip" "$VALIDATOR_CONFIG")

# Set up environment for parse-vc.sh and client-cmd.sh
# These scripts expect: $item, $configDir, $dataDir, $scriptDir, $validatorConfig
export scriptDir="$SCRIPT_DIR"
export configDir="$GENESIS_DIR"
export dataDir="$PROJECT_ROOT/shadow.data/hosts/$hostname"
export validatorConfig="$VALIDATOR_CONFIG"

# Source parse-vc.sh to extract per-node config (quicPort, metricsPort, apiPort, etc.)
# parse-vc.sh uses $item and $configDir
source "$SCRIPT_DIR/parse-vc.sh"

# Source client-cmd.sh to get node_binary
node_setup="binary"
client_cmd="$SCRIPT_DIR/client-cmds/${client}-cmd.sh"
if [ ! -f "$client_cmd" ]; then
echo "❌ Error: Client command script not found: $client_cmd"
Comment on lines +162 to +166
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This script sources each client-cmds/*-cmd.sh, which (per existing quickstart contract) sets node_setup and provides both node_binary and node_docker. However, the generated Shadow config always uses node_binary later, even when node_setup="docker" (e.g., leanspec-cmd.sh explicitly selects docker). Either honor node_setup (and build an executable command accordingly), or fail fast with a clear error if a client is configured for docker-only so Shadow runs don’t silently generate a non-working config.

Copilot uses AI. Check for mistakes.
echo " Available clients:"
ls "$SCRIPT_DIR/client-cmds/"*-cmd.sh 2>/dev/null | sed 's/.*\// /' | sed 's/-cmd.sh//'
exit 1
fi
source "$client_cmd"

# node_binary is now set by the client-cmd.sh script
# Convert relative paths to absolute paths for Shadow
# Extract the binary path (first word) and args (rest)
binary_path=$(echo "$node_binary" | awk '{print $1}')
binary_args=$(echo "$node_binary" | sed "s|^[^ ]*||")

# Make binary path absolute
if [[ "$binary_path" != /* ]]; then
binary_path="$(cd "$(dirname "$binary_path")" 2>/dev/null && pwd)/$(basename "$binary_path")" 2>/dev/null || binary_path="$PROJECT_ROOT/${binary_path#./}"
Comment on lines +179 to +181
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic that makes binary_path absolute treats any command that doesn't start with / as a filesystem path. This breaks PATH-resolved commands like uv (used by leanspec-cmd.sh), turning it into <cwd>/uv which likely doesn't exist. Only absolutize when the command contains a / (i.e., is a path), or resolve bare commands via command -v and keep them unchanged if found on PATH.

Suggested change
# Make binary path absolute
if [[ "$binary_path" != /* ]]; then
binary_path="$(cd "$(dirname "$binary_path")" 2>/dev/null && pwd)/$(basename "$binary_path")" 2>/dev/null || binary_path="$PROJECT_ROOT/${binary_path#./}"
# Make binary path absolute when it is a filesystem path.
# - If binary_path starts with '/', it is already absolute.
# - If binary_path contains '/', treat it as a path and absolutize it.
# - If binary_path has no '/', treat it as a bare command and leave it for PATH resolution.
if [[ "$binary_path" != /* ]]; then
if [[ "$binary_path" == */* ]]; then
binary_path="$(cd "$(dirname "$binary_path")" 2>/dev/null && pwd)/$(basename "$binary_path")" 2>/dev/null || binary_path="$PROJECT_ROOT/${binary_path#./}"
else
# Bare command: verify it exists on PATH but do not rewrite it into a filesystem path.
if ! command -v "$binary_path" >/dev/null 2>&1; then
echo "⚠️ Warning: binary '$binary_path' not found on PATH; Shadow may fail to start this process." >&2
fi
fi

Copilot uses AI. Check for mistakes.
fi

# Make all path args absolute: replace $configDir, $dataDir references with absolute paths
# The client-cmd.sh already uses $configDir and $dataDir which we set to absolute paths

# Write host entry
cat >> "$OUTPUT_FILE" << EOF
$hostname:
network_node_id: 0
ip_addr: $ip
processes:
- path: $binary_path
args: >-
$binary_args
start_time: 1s
expected_final_state: running
Comment on lines +193 to +197
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated YAML under processes: is incorrectly indented (- path is aligned with processes:). This will produce invalid YAML (or an unexpected structure) for Shadow. Indent the process list items under processes: (and their nested keys accordingly).

Suggested change
- path: $binary_path
args: >-
$binary_args
start_time: 1s
expected_final_state: running
- path: $binary_path
args: >-
$binary_args
start_time: 1s
expected_final_state: running

Copilot uses AI. Check for mistakes.

EOF

echo " ✅ $item → $hostname ($ip) [$client]"
done

echo ""
echo "📄 Shadow config written to: $OUTPUT_FILE"
echo " Stop time: $STOP_TIME"
echo " Nodes: $node_count"
Loading
Loading