Run a local Kurtosis Ethereum testnet against erigon
This skill mirrors the CI workflow at .github/workflows/test-kurtosis-assertoor.yml
but runs locally via the raw kurtosis CLI. The CI uses the
ethpandaops/kurtosis-assertoor-github-action@v1 wrapper, which is not portable outside
GitHub Actions; this skill drives kurtosis run, kurtosis enclave inspect,
kurtosis service logs, and kurtosis enclave dump directly.
The skill takes an ethereum-package YAML config, builds the local
test/erigon:current Docker image, starts a Kurtosis enclave, monitors
EL/CL/assertoor/spamoor health, triages failures (challenging peer clients against
erigon to identify the offender), and iterates a fix → rebuild → rerun loop until the
testnet is stable or max-attempts is reached.
Inputs
The model parses these arguments and binds them to the shell variables used in the
bash blocks below: $1/$2 are positional; duration=Nm → duration_secs,
auto=true|false → auto, max-attempts=N → max_attempts.
| Argument | Default | Notes |
|---|---|---|
$1 config path |
required | Path to an ethereum-package args YAML. The reference set lives in .github/workflows/kurtosis/, but that directory also contains assertoor playbooks (id: / tasks: schema) — only the files whose top-level keys are participants: or participants_matrix: are valid here. To list candidates: grep -lE '^participants(_matrix)?:' .github/workflows/kurtosis/*.io. |
$2 enclave name |
kurtosis-test-<unix-ts> |
Used for kurtosis run --enclave. Each rerun gets a fresh timestamp. |
duration=Nm |
20m |
Wall-clock window the monitor watches before declaring "stable" if no failures trip. |
auto=true|false |
true |
If true, the fix-rebuild-rerun loop runs autonomously up to max-attempts. If false, pause for user approval before each fix. |
max-attempts=N |
5 |
Cap on fix-loop iterations. After hitting the cap, halt and surface the per-attempt triage history. |
Prerequisites
- Docker running:
docker info >/dev/nullshould succeed. - Kurtosis CLI installed:
kurtosis version. Install from https://docs.kurtosis.com/install if missing. CLI ≥ 1.18.1 is only needed when the--package@branchyou run includes theGpuConfigStarlark built-in (i.e.ethereum-packagemainpost commit835dd9b). The pinned branches in the mapping table below — includingglamsterdam's6.1.0— predate that change, so they work on older CLIs. See Troubleshooting if you hit aGpuConfigStarlark error. - Erigon source tree at the cwd:
Makefileexists andgo.modcontainsmodule github.com/erigontech/erigon. curlandjqon$PATH— used by the monitor / triage snippets below to poll the EL JSON-RPC endpoint and the assertoor API.- Fork detection — skim the YAML for
_fork_epochkeys. The repo's configs use the CL-side fork names:deneb_fork_epoch(Cancun on EL),electra_fork_epoch(Prague),fulu_fork_epoch(Osaka),gloas_fork_epoch(Amsterdam). Future fork keys will follow the same CL-naming convention. Feeds the spec-lookup section below.
Spec lookup (when debugging unfamiliar forks/EIPs)
If the YAML enables a fork under development, invoke /erigon-implement-eip Steps 2–4
to fetch:
- Step 2 — referenced/dependent EIPs.
- Step 3 — the meta EIP enumerating which EIPs the fork includes (CFI/SFI/PFI/DFI lists).
- Step 4 — the latest devnet specification at
https://notes.ethereum.org/@ethpandaops/<devnet>.
Use these as ground truth when triaging. For specific opcodes / state transitions, also pull the EIP body via Step 1. If anything in the spec is contradictory or ambiguous, stop and ask the user rather than guessing — the same rule the EIP skill enforces.
Build the erigon docker image
The image tag must be exactly test/erigon:current because every
.github/workflows/kurtosis/*.io config references that tag.
docker build -t test/erigon:current --build-arg BINARIES="erigon caplin" .
caplin is required in BINARIES because some configs (e.g.
caplin-minimal-assertoor.io) use erigon as the CL via the same image.
Always rebuild before each run — the same approach the CI uses. BuildKit's layer cache
makes the no-op rebuild fast, and the fix → rebuild → rerun loop necessarily picks up
uncommitted source edits this way (a freshness check against git log would miss them
and silently run a stale image).
If the user asks for a from-scratch binary build instead of docker, point them at
/erigon-build; this skill itself uses docker because the kurtosis configs reference a
docker image tag.
Suite → ethereum-package branch mapping (from CI)
The CI matrix pins different package branches per suite. Use the same pinning when the
config matches a known CI file; for unknown configs, default to 5.0.1 and ask the
user to confirm.
| Config file | --package@branch |
|---|---|
regular-assertoor.io |
github.com/ethpandaops/ethereum-package@5.0.1 |
pectra.io |
github.com/ethpandaops/ethereum-package@5.0.1 |
glamsterdam.io |
github.com/ethpandaops/ethereum-package@6.1.0 |
caplin-assertoor.io |
github.com/erigontech/ethereum-package@erigontech/fix-caplin-launcher |
caplin-minimal-assertoor.io |
github.com/erigontech/ethereum-package@erigontech/fix-caplin-launcher |
| (other / user-supplied) | default 5.0.1, prompt user if unsure |
Note: glamsterdam is pinned to 6.1.0 rather than main because main introduced
the GpuConfig Starlark built-in which requires kurtosis CLI ≥ 1.18.1. Caplin suites
(caplin-assertoor.io, caplin-minimal-assertoor.io) require the erigontech fork —
do not let them fall back to the default 5.0.1.
Start the testnet
ENCLAVE="${2:-kurtosis-test-$(date +%s)}"
CONFIG="$1"
# Map config basename → ethereum-package branch (mirrors the table above).
case "$(basename "$CONFIG")" in
glamsterdam.io)
PACKAGE_REF="github.com/ethpandaops/ethereum-package@6.1.0" ;;
caplin-assertoor.io|caplin-minimal-assertoor.io)
PACKAGE_REF="github.com/erigontech/ethereum-package@erigontech/fix-caplin-launcher" ;;
regular-assertoor.io|pectra.io)
PACKAGE_REF="github.com/ethpandaops/ethereum-package@5.0.1" ;;
*)
PACKAGE_REF="github.com/ethpandaops/ethereum-package@5.0.1" ;;
esac
kurtosis run \
"$PACKAGE_REF" \
--enclave "$ENCLAVE" \
--args-file "$CONFIG" \
--verbosity detailed --cli-log-level trace
Once kurtosis run returns, capture service names and host-mapped ports:
kurtosis enclave inspect "$ENCLAVE" --full-uuids
# Pick the first erigon EL service. `kurtosis enclave inspect` prints columnar
# rows (UUID first, then service name), so we match against field 2.
EL_SERVICE=$(kurtosis enclave inspect "$ENCLAVE" --full-uuids 2>/dev/null \
| awk '$2 ~ /^el-[0-9]+-erigon-[a-z]+$/ {print $2; exit}')
EL_RPC_PORT=$(kurtosis port print "$ENCLAVE" "$EL_SERVICE" rpc 2>/dev/null \
| sed -E 's|.*:([0-9]+).*|\1|')
# CL endpoint (whichever client paired with that EL)
CL_SERVICE=$(kurtosis enclave inspect "$ENCLAVE" --full-uuids 2>/dev/null \
| awk '$2 ~ /^cl-[0-9]+-[a-z]+-erigon$/ {print $2; exit}')
CL_HTTP_PORT=$(kurtosis port print "$ENCLAVE" "$CL_SERVICE" http 2>/dev/null \
| sed -E 's|.*:([0-9]+).*|\1|')
# Optional services
ASSERTOOR_PORT=$(kurtosis port print "$ENCLAVE" assertoor http 2>/dev/null | sed -E 's|.*:([0-9]+).*|\1|')
DORA_PORT=$(kurtosis port print "$ENCLAVE" dora http 2>/dev/null | sed -E 's|.*:([0-9]+).*|\1|')
SPAMOOR_PORT=$(kurtosis port print "$ENCLAVE" spamoor http 2>/dev/null | sed -E 's|.*:([0-9]+).*|\1|')
Print the assertoor / dora URLs so the user can open the dashboards in a browser.
Monitor
Three checks run on a polling loop until either (a) duration elapses, or (b) any
failure trips. All three are captured in the run history.
Check A — Block height progress
duration_secs is the parsed duration=Nm input (default 1200). Three terminal
outcomes:
- STABLE — chain produced blocks and the duration elapsed without a stall.
- STALL — chain produced at least one block, then stopped advancing for
>3 × seconds_per_slot. - NO_PROGRESS — chain never produced a block within
duration_secs(e.g. validators didn't start).
duration_secs=${duration_secs:-1200}
prev=0
slot_secs=$(grep -E '^\s*seconds_per_slot:' "$CONFIG" | awk '{print $2}'); slot_secs=${slot_secs:-12}
poll_interval=$(( slot_secs * 2 ))
stall_window=$(( slot_secs * 3 ))
start=$(date +%s)
end=$(( start + duration_secs ))
stall_deadline=$(( start + stall_window ))
outcome=""
while [ "$(date +%s)" -lt "$end" ]; do
height_hex=$(curl -s --max-time 5 "http://127.0.0.1:${EL_RPC_PORT}" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
| jq -r '.result // empty')
if [[ "$height_hex" =~ ^0x[0-9a-fA-F]+$ ]]; then
height=$(printf '%d\n' "$height_hex")
echo "[$(date -u +%H:%M:%S)] height=$height"
if [ "$height" -gt "$prev" ]; then
prev=$height
stall_deadline=$(( $(date +%s) + stall_window ))
fi
else
echo "[$(date -u +%H:%M:%S)] RPC unreachable or invalid response — retrying"
fi
# Only declare a stall after seeing at least one block. Many configs set
# genesis_delay > stall_window (e.g. glamsterdam.io: 20s delay vs 18s window
# at 6s slots), so the pre-genesis gap would otherwise trip a false stall.
if [ "$prev" -gt 0 ] && [ "$(date +%s)" -gt "$stall_deadline" ]; then
outcome="STALL: chain not progressing for >${stall_window}s (last height=$prev)"
break
fi
sleep "$poll_interval"
done
if [ -z "$outcome" ]; then
if [ "$prev" -eq 0 ]; then
outcome="NO_PROGRESS: chain never produced a block within ${duration_secs}s"
else
outcome="STABLE: chain progressed for full ${duration_secs}s window (final height=$prev)"
fi
fi
echo "$outcome"
Pass: height advances ≥1 within every 3 × seconds_per_slot (the stall window),
sustained for the full duration_secs (poll cadence is 2 × seconds_per_slot).
Fail: STALL or NO_PROGRESS.
Check B — Assertoor results
curl -s "http://127.0.0.1:${ASSERTOOR_PORT}/api/v1/test_runs" \
| jq '.data[] | {name, status, result}'
Pass: every test_run has result=success. Fail: any result=failure, or any test
stuck pending / running past 3× its expected duration. The assertoor web UI at
http://127.0.0.1:${ASSERTOOR_PORT}/ shows per-step trees; use it for deep dives.
Check C — Erigon-focused log scan
kurtosis service logs "$ENCLAVE" "$EL_SERVICE" 2>&1 \
| grep -iE 'panic|fatal|^ERROR|"lvl"="error"|consensus failure|invalid block' \
| tail -200
For cross-client comparison (used by the triage section), run the same scan across every EL/CL service:
for svc in $(kurtosis enclave inspect "$ENCLAVE" --full-uuids \
| awk '$2 ~ /^(el|cl|vc)-[0-9]+-[a-z]+-[a-z]+$/ {print $2}'); do
echo "=== $svc ==="
kurtosis service logs "$ENCLAVE" "$svc" 2>&1 \
| grep -iE 'error|panic|fatal' | tail -30
done
If snooper-engine-* services exist (when snooper_enabled: true in the YAML), pull
their logs too — they capture the full Engine API request/response trace, invaluable
when an EL bug is suspected.
Issue detection criteria
| User check | Pass | Fail |
|---|---|---|
| 1. Block production / height progress | eth_blockNumber advances ≥1 every 2 × seconds_per_slot, sustained for duration |
No advance for >3 × seconds_per_slot, OR explicit chain reorg / fork-choice loop in CL logs |
| 2. EL/CL log errors (focus erigon) | No panic, fatal, or error-level lines in any erigon service |
Any erigon-side panic/fatal/consensus failure. Non-erigon errors recorded but informational unless they crash the peer. |
| 3. Assertoor test failures | All assertoor test_runs reach result=success |
Any result=failure, OR a test stuck pending/running past 3× expected duration |
A single failed check trips the triage section. Block-stall + erigon panic + assertoor fail are independent signals — record all three in the run history; do not stop at the first.
Debugging methodology — triage erigon vs peer-client vs network/config
Decision tree:
- Reproduce. A single one-shot failure gets one re-run before triaging. Truly intermittent failures still get triaged, but flag them as flaky.
- Classify the symptom. One of: block-stall, EL panic, EL invalid-payload, CL fork-choice mismatch, assertoor opcode/EIP test failure, spamoor tx-submission failure.
- Cross-client comparison. For each erigon-side error, find the equivalent moment
in the peer-client log at the same slot/block. Three outcomes:
- Erigon wrong: erigon rejects/panics; peer client + assertoor accept the block → erigon bug, fix locally.
- Peer wrong: erigon accepts; peer rejects → check peer-client image tag against the fork's expected tag (often a stale image). Surface to user; do not fix erigon.
- Both disagree with spec: clients produce different "valid" answers from what the EIP spec says → escalate to the user. Likely spec ambiguity or a misread.
- Cross-reference the spec. Pull the relevant EIP (
/erigon-implement-eipStep 1) and the devnet spec (/erigon-implement-eipStep 4) for the failing block, opcode, or state transition. - Rule out config drift. Diff the YAML's
el_extra_params,network_params, and fork epochs against the equivalent CI suite under.github/workflows/kurtosis/. Mismatches there are config bugs, not erigon bugs. - Rule out enclave plumbing.
kurtosis service exec <enclave> <svc> "ping <other_svc>"to verify network reachability; check JWT mounting viakurtosis service exec <enclave> <el-svc> "ls -la /jwt/". The CLI takes the command as a single positional arg (multi-word commands must be quoted) — there is no--separator.
Triage table
| Symptom | Likely owner | Next action |
|---|---|---|
Erigon panic with stack trace inside execution/... |
Erigon | Capture stack, find offending call in repo, propose fix |
eth_newPayloadV4 returns INVALID; CL logs say block is valid; assertoor passes elsewhere |
Erigon (likely block-validation divergence) | Replay the payload via debug_traceBlockByNumber / debug_traceBlockByHash; check fork activation timestamp |
| All EL clients stop progressing after a specific slot | Config (fork epoch wrong) or shared dep | Diff YAML against working CI suite; check ethereum-package branch |
Assertoor block-proposal-check fails on slot N for vc-N-erigon-… |
Erigon block builder | Fetch block N body via RPC; replay locally |
Assertoor synchronized-check fails |
Network plumbing | Inspect peer counts; kurtosis service exec connectivity test |
caplin panics but lighthouse runs fine on the same EL |
Caplin | Edit YAML to swap CL to lighthouse for bisection; report to user |
| Spamoor reports persistent "insufficient funds" / "nonce too low" | Spamoor config | Increase funding_gas_limit; lower throughput; check prefunded keys |
| Snooper shows malformed Engine API request | Erigon RPC layer | Capture the request from snooper logs; inspect erigon engine handler |
| Erigon accepts a payload that lighthouse + teku both reject | Erigon (single-client divergence) | Almost always an erigon bug — fix locally |
eth_blockNumber stays at 0x0 after >2 epochs |
Validators not running | Check vc-* service logs; verify keystore mounting |
Fix-rebuild-rerun loop
Auto-iterates by default (auto=true), capped at max-attempts=5. Per attempt:
- Tear down:
kurtosis enclave rm -f "$ENCLAVE". - Apply the fix to erigon source via
Edit. Only auto-apply when the triage classified the issue as "Erigon wrong" with high confidence. If ambiguous (peer could be wrong, or spec interpretation unclear), pause and surface to the user even before the cap — that overridesauto=true. - Rebuild image:
docker build -t test/erigon:current --build-arg BINARIES="erigon caplin" .. - Re-launch: same config, fresh timestamped enclave name (so each attempt's dump stays separate).
- Re-run monitor: same three checks.
- Record: per attempt — symptom, hypothesis, fix applied, outcome.
After max-attempts consecutive failures, halt and print the per-attempt history. Do
not auto-apply more fixes once the cap is hit. Reference: /autoresearch follows the
same iterate-and-record pattern.
If auto=false, pause for user approval before steps 2–4 of every attempt.
Cleanup (always run)
Run as the final step of every iteration regardless of outcome (success, failure, or user interrupt):
DUMP_DIR="/tmp/kurtosis-dump-${ENCLAVE}"
# `kurtosis enclave dump` refuses if the destination already exists — never pre-create it.
kurtosis enclave dump "$ENCLAVE" "$DUMP_DIR" || true
kurtosis enclave rm -f "$ENCLAVE" || true
echo "Logs dumped to: $DUMP_DIR"
The dump contains per-service logs (el-*, cl-*, vc-*, assertoor, spamoor,
dora, snooper-*) — keep this directory until triage is complete; GitHub blob
storage is not in play here so the logs are only on disk locally.
After multiple iterations, also prune dangling docker images:
docker image prune -f
Troubleshooting
| Problem | Solution |
|---|---|
kurtosis run fails with Starlark error mentioning GpuConfig |
Use one of the pinned --package@branch values from the mapping table (e.g. 6.1.0 for glamsterdam, 5.0.1 for regular/pectra) — they predate the GpuConfig built-in. OR upgrade kurtosis CLI to ≥ 1.18.1 if you need an ethereum-package branch that includes it. |
kurtosis enclave dump errors "destination exists" |
Use a fresh dir name or rm -rf it first |
All el-*-erigon-* services missing from enclave inspect |
Image build failed; check docker images | grep test/erigon and rerun docker build |
eth_blockNumber returns 0x0 forever |
Validators didn't start; check vc-* service logs; verify keystore mounting |
| Connection refused on assertoor port | Service still booting; or assertoor not in additional_services in the YAML |
kurtosis service logs truncates very long logs |
Use kurtosis enclave dump for the full per-service log files |
caplin-minimal config fails with "binary not found" |
Confirm BINARIES="erigon caplin" in the docker build args |
eth_blockNumber advances but assertoor reports timeout |
Slot time / preset mismatch; check seconds_per_slot and preset in YAML |
| Erigon image stale despite rebuild | docker image rm test/erigon:current && docker build ... to force; check BuildKit cache scope |
| Port already allocated | Another enclave is running — kurtosis enclave ls then kurtosis enclave rm -f <old> |
| Engine API JWT mismatch in EL logs | Check kurtosis service exec <enclave> el-1-erigon-… "ls -la /jwt/"; restart enclave if missing |