Skip to content

Commit b85cbb4

Browse files
committed
tests/shadow-testing: follow mode forks N hours back, adds catch-up phase
- 10-follow-up.sh: FOLLOW_FORK_HOURS_BACK (default 5) — fork at tip - hours*300 blocks so the run opens with a real backlog (committed-but-not-finalized at fork + everything produced since); lastFinalized/lastCommitted now read AT the fork block; 0 = old fork-at-tip behavior. follow-run.env records FORK_HOURS_BACK and MAINNET_BUNDLE_TIP_AT_START. - generate-catchup-report.py: split report into catch-up phase (fork finalized index climbing to the recorded mainnet bundle tip) and steady-state phase; use the max_finalized_bundle watermark for finalized counts; backward-compatible with old env files. - docs: follow README/GUIDE + AGENTS.md describe catch-up-then-follow.
1 parent ee217b3 commit b85cbb4

5 files changed

Lines changed: 110 additions & 25 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Follow the structured testing guide in [`docs/testing/openvm-upgrade-testing-gui
2424

2525
For testing proof generation against **real mainnet production tasks** without interfering with the live system, use the **Shadow Coordinator** approach. This is significantly faster than a full shadow fork. There are two test modes, each in its own directory under `tests/shadow-testing/`:
2626

27-
- **Follow mode (primary/default)**`cd tests/shadow-testing/follow && make follow` forks the current ETH mainnet state and follows mainnet bundle production in real time (poll-sync → prove → finalize, default 48h window). This is the default acceptance test for prover/guest upgrades. Operations: `make follow-status`, `make follow-report`, `make follow-stop`, `make re-fork`.
27+
- **Follow mode (primary/default)**`cd tests/shadow-testing/follow && make follow` forks the ETH mainnet state `FOLLOW_FORK_HOURS_BACK` hours in the past (default 5h; `0` = current tip), catches up the resulting backlog, then follows mainnet bundle production in real time (poll-sync → prove → finalize, default 48h window). This is the default acceptance test for prover/guest upgrades. Operations: `make follow-status`, `make follow-report`, `make follow-stop`, `make re-fork`.
2828
- **Snapshot replay mode (specialized)**`cd tests/shadow-testing/snapshot`: fork a historical block, import a fixed bundle range, prove & finalize ~N bundles (`make all` / `make docker-all` / `make sepolia-all`). Use for incident reproduction, single-bundle debugging, Sepolia testing, targeted codec-migration checks.
2929

3030
Shared details:

tests/shadow-testing/follow/GUIDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Follow Mode Guide — Shadow Coordinator + Prover Testing
22

3-
This guide documents the **follow mode** of the shadow coordinator + local prover environment: fork the current ETH mainnet state and follow mainnet bundle production in real time. This is the **default acceptance test for prover/guest upgrades**.
3+
This guide documents the **follow mode** of the shadow coordinator + local prover environment: fork the ETH mainnet state from `FOLLOW_FORK_HOURS_BACK` hours in the past (default 5h), catch up the resulting backlog, then follow mainnet bundle production in real time. This is the **default acceptance test for prover/guest upgrades**.
44

55
For a fixed historical bundle range (incident reproduction, single-bundle debugging, Sepolia testing, targeted codec-migration checks), see the [Snapshot Replay Mode Guide](../snapshot/GUIDE.md). Shared scripts live in [`../lib/`](../lib/), pitfalls in [`./TROUBLESHOOTING.md`](./TROUBLESHOOTING.md) (follow mode) and [`../docs/COMMON-TROUBLESHOOTING.md`](../docs/COMMON-TROUBLESHOOTING.md) (mode-independent). Runtime state (`.work/`) is shared by both modes at `tests/shadow-testing/.work` (i.e. `../.work` from here).
66

@@ -41,7 +41,7 @@ For a fixed historical bundle range (incident reproduction, single-bundle debugg
4141

4242
# Follow Mode (Primary)
4343

44-
Follow mode forks the **current** ETH mainnet state with Anvil, baseline-syncs the shadow DB from the mainnet read replica, then keeps following mainnet indefinitely — poll-syncing new chunk/batch/bundle rows, proving them with local GPU provers, and finalizing bundles on the fork — until a preset duration (`FOLLOW_RUN_HOURS`, default 48) or a failure. It answers the question a snapshot cannot: *can this prover fleet keep pace with real mainnet bundle production?*
44+
Follow mode forks the ETH mainnet state from **`FOLLOW_FORK_HOURS_BACK` hours in the past** (default 5; `0` = fork at the current tip) with Anvil, baseline-syncs the shadow DB from the mainnet read replica, then **catches up the backlog** (every bundle committed-but-not-finalized at the fork block, plus everything mainnet produced since) before **following** mainnet indefinitely — poll-syncing new chunk/batch/bundle rows, proving them with local GPU provers, and finalizing bundles on the fork — until a preset duration (`FOLLOW_RUN_HOURS`, default 48) or a failure. It answers the question a snapshot cannot: *can this prover fleet catch up a backlog and then keep pace with real mainnet bundle production?* The fork block is `tip - FOLLOW_FORK_HOURS_BACK*300` (12s L1 blocks); `lastFinalizedBatchIndex` and `lastCommittedBatchIndex` are read **at the fork block**, so the baseline window (fork-finalized → mainnet tip) is exactly the backlog. `make follow-report` splits metrics into catch-up and steady-state phases using `MAINNET_BUNDLE_TIP_AT_START` recorded in `.work/follow-run.env`.
4545

4646
For a fixed historical bundle range (incident reproduction, single-bundle debugging, Sepolia, codec-migration checks), use the [Snapshot Replay Mode Guide](../snapshot/GUIDE.md).
4747

tests/shadow-testing/follow/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Follow Mode (Primary)
22

3-
Follow mode forks the **current** ETH mainnet state, baseline-syncs the shadow DB from the mainnet read replica, then follows mainnet indefinitely — poll-syncing new chunk/batch/bundle rows, proving them with local GPU provers, and finalizing on the fork — for a preset window (default 48h). It is the **default acceptance test for prover/guest upgrades**: it answers whether the prover fleet can keep pace with real mainnet bundle production.
3+
Follow mode forks the ETH mainnet state from **FOLLOW_FORK_HOURS_BACK hours in the past** (default 5h), baseline-syncs the shadow DB from the mainnet read replica, then first **catches up** the resulting backlog (all bundles committed-but-not-finalized at the fork block plus everything mainnet produced since) and then **follows** mainnet indefinitely — poll-syncing new chunk/batch/bundle rows, proving them with local GPU provers, and finalizing on the fork — for a preset window (default 48h). It is the **default acceptance test for prover/guest upgrades**: it answers whether the prover fleet can catch up a backlog *and* keep pace with real mainnet bundle production. `FOLLOW_FORK_HOURS_BACK=0` forks at the current tip (no backlog). The final report splits metrics into catch-up and steady-state phases.
44

55
## Quick Start
66

tests/shadow-testing/follow/scripts/10-follow-up.sh

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
#!/usr/bin/env bash
22
# 10-follow-up.sh — one-shot orchestrator for "follow mode" shadow testing.
33
#
4-
# Brings up the full follow-mode stack against the CURRENT mainnet tip:
4+
# Brings up the full follow-mode stack against a RECENT mainnet state:
55
# a. sanity checks (postgres 5433, mainnet DSN, alchemy key, stale ports)
66
# b. baseline DB sync from the mainnet read replica
7-
# c. Anvil fork at the current latest L1 block (via 01-setup-anvil.sh)
7+
# c. Anvil fork at tip - FOLLOW_FORK_HOURS_BACK*300 blocks (default 5h back,
8+
# via 01-setup-anvil.sh) so the run opens with a real backlog to catch up
89
# d. verifier wrapper deploy + registration (via 03-deploy-verifier.sh)
910
# e. initial L1 queue rolling-hash sync (Trap 23)
1011
# f. coordinator_api + coordinator_cron
@@ -14,7 +15,8 @@
1415
# j. records run metadata in .work/follow-run.env
1516
#
1617
# Idempotent-ish: a component whose pidfile exists and is alive is skipped.
17-
# Usage: ./10-follow-up.sh [--config configs/mainnet.json] [--dry-run]
18+
# Usage: ./10-follow-up.sh [--config configs/mainnet.json] [--dry-run] [--reset]
19+
# Env: FOLLOW_FORK_HOURS_BACK=0 forks at the current tip (no backlog).
1820

1921
set -euo pipefail
2022

@@ -31,6 +33,11 @@ WORK_DIR="${WORK_DIR:-${PROJECT_ROOT}/../.work}"
3133
# Default mirrors sync-mainnet-db.py; override via env if the tunnel differs.
3234
MAINNET_DSN="${MAINNET_DSN:-postgresql://mainnet_infra_team_read_only:AuexDUuaarskbG6tr9CH9gXsJqp4at67mddAbMrt@localhost:15432/mainnet_rollup}"
3335
FOLLOW_RUN_HOURS="${FOLLOW_RUN_HOURS:-48}"
36+
# Fork the L1 state this many hours in the PAST, so the run starts with a
37+
# real backlog (all bundles committed-but-not-finalized at the fork block,
38+
# plus everything mainnet produced since) to catch up before steady-state
39+
# following. 0 = fork at the current tip (old behavior, near-idle start).
40+
FOLLOW_FORK_HOURS_BACK="${FOLLOW_FORK_HOURS_BACK:-5}"
3441
SYNC_POLL_INTERVAL="${SYNC_POLL_INTERVAL:-60}"
3542
SWEEP_INTERVAL="${SWEEP_INTERVAL:-600}"
3643
MONITOR_INTERVAL="${MONITOR_INTERVAL:-3600}"
@@ -157,12 +164,33 @@ check_port_free 8391 coordinator-cron
157164
check_port_free 18545 anvil
158165

159166
# ─── b. Baseline DB sync ─────────────────────────────────────────────────────
160-
# Window = mainnet's finalization lag, NOT history: L1 finalization is
161-
# sequential (each finalize tx chains prevBatchHash), so the shadow must prove
162-
# and finalize every committed-but-not-finalized bundle starting from the
163-
# fork's lastFinalizedBatchIndex. Anything older is already finalized on the
164-
# fork and is copied only as a parent reference (marked done post-baseline).
167+
# Window = everything NOT finalized at the fork block, up to the mainnet tip:
168+
# L1 finalization is sequential (each finalize tx chains prevBatchHash), so the
169+
# shadow must prove and finalize every committed-but-not-finalized bundle
170+
# starting from the fork's lastFinalizedBatchIndex. With FOLLOW_FORK_HOURS_BACK
171+
# > 0 this window covers the fork-back backlog PLUS everything mainnet produced
172+
# between the fork block and now — that backlog is the run's catch-up phase.
173+
# Anything older is already finalized on the fork and is copied only as a
174+
# parent reference (marked done post-baseline).
175+
#
176+
# Resolve the fork block FIRST (rather than passing "latest") so that (a) the
177+
# point is logged and reproducible, and (b) lastFinalized/lastCommitted below
178+
# are read AT the fork block, not at the tip — with a fork-back hours this
179+
# difference is exactly the backlog size. 300 blocks/hour = 12s L1 block time.
165180
log_info "=== b. Baseline DB sync ==="
181+
LATEST_BLOCK=$(cast block-number --rpc-url "$FORK_URL")
182+
if [[ "$FOLLOW_FORK_HOURS_BACK" -gt 0 ]]; then
183+
FORK_BLOCK_NUMBER=$((LATEST_BLOCK - FOLLOW_FORK_HOURS_BACK * 300))
184+
else
185+
FORK_BLOCK_NUMBER=$LATEST_BLOCK
186+
fi
187+
log_info " fork block: $FORK_BLOCK_NUMBER (latest $LATEST_BLOCK, ${FOLLOW_FORK_HOURS_BACK}h back)"
188+
LAST_FINALIZED=$(cast call "$SCROLL_CHAIN" "lastFinalizedBatchIndex()(uint256)" --rpc-url "$FORK_URL" --block "$FORK_BLOCK_NUMBER" | awk '{print $1}')
189+
MAINNET_TIP=$(psql "$MAINNET_DSN" -Atq -c "SELECT MAX(index) FROM batch" | tr -d ' ')
190+
MAINNET_BUNDLE_TIP=$(psql "$MAINNET_DSN" -Atq -c "SELECT MAX(index) FROM bundle" | tr -d ' ')
191+
BASELINE_START=$((LAST_FINALIZED - 1))
192+
log_info " lastFinalizedBatchIndex@fork: $LAST_FINALIZED; mainnet batch tip: $MAINNET_TIP"
193+
log_info " baseline window: ${BASELINE_START}..${MAINNET_TIP} ($((MAINNET_TIP - BASELINE_START)) batches = backlog + finalization lag)"
166194
if $RESET_DB; then
167195
# --reset: wipe task tables so the DB contains ONLY the finalization-lag
168196
# window. Without this, stale rows from earlier runs/imports (thousands of
@@ -198,11 +226,6 @@ if $RESET_DB; then
198226
rm -f "${WORK_DIR}/anvil-${CONFIG_NAME}.state.json" "${WORK_DIR}/verifier.env" "${WORK_DIR}/follow-run.env"
199227
fi
200228
fi
201-
LAST_FINALIZED=$(cast call "$SCROLL_CHAIN" "lastFinalizedBatchIndex()(uint256)" --rpc-url "$FORK_URL" | awk '{print $1}')
202-
MAINNET_TIP=$(psql "$MAINNET_DSN" -Atq -c "SELECT MAX(index) FROM batch" | tr -d ' ')
203-
BASELINE_START=$((LAST_FINALIZED - 1))
204-
log_info " lastFinalizedBatchIndex: $LAST_FINALIZED; mainnet batch tip: $MAINNET_TIP"
205-
log_info " baseline window: ${BASELINE_START}..${MAINNET_TIP} ($((MAINNET_TIP - BASELINE_START)) batches = finalization lag)"
206229
export MAINNET_DSN
207230
run_step python3 "${SCRIPT_DIR}/sync-mainnet-db.py" --init-from-batch "$BASELINE_START"
208231
# Boundary rows (<= lastFinalized) are already finalized on the fork: mark
@@ -214,7 +237,7 @@ run_step python3 "${SCRIPT_DIR}/sync-mainnet-db.py" --init-from-batch "$BASELINE
214237
# so the relayer re-commits/re-finalizes them in chain order. Proofs already
215238
# generated stay valid (proof content depends on L2 data, not L1 state).
216239
if ! $DRY_RUN; then
217-
MISC_RAW=$(cast call "$SCROLL_CHAIN" 0x06582acb --rpc-url "$FORK_URL")
240+
MISC_RAW=$(cast call "$SCROLL_CHAIN" 0x06582acb --rpc-url "$FORK_URL" --block "$FORK_BLOCK_NUMBER")
218241
FORK_COMMITTED=$((16#${MISC_RAW:2:64}))
219242
log_info " fork lastCommittedBatchIndex: $FORK_COMMITTED"
220243
psql "$SHADOW_DSN" -Atq -c "
@@ -234,11 +257,10 @@ if ! $DRY_RUN; then
234257
log_ok " rollup_status aligned to fork boundaries (finalized<=${LAST_FINALIZED}, committed<=${FORK_COMMITTED})"
235258
fi
236259

237-
# ─── c. Anvil fork at the CURRENT latest block ───────────────────────────────
238-
log_info "=== c. Anvil fork at latest mainnet block ==="
239-
# Resolve the block number explicitly (rather than passing "latest") so the
240-
# fork point is logged and reproducible.
241-
FORK_BLOCK_NUMBER=$(cast block-number --rpc-url "$FORK_URL")
260+
# ─── c. Anvil fork at the chosen block ───────────────────────────────────────
261+
log_info "=== c. Anvil fork setup ==="
262+
# FORK_BLOCK_NUMBER was resolved in step b (tip, or tip - FOLLOW_FORK_HOURS_BACK
263+
# * 300). lastFinalized was read at that same block there.
242264
log_info " fork block: $FORK_BLOCK_NUMBER"
243265
log_info " lastFinalized: $LAST_FINALIZED (read from mainnet at fork block)"
244266
log_info " nextQueueIndex: $NEXT_QUEUE"
@@ -435,7 +457,10 @@ SHADOW_REPORT_START=${REPORT_START}
435457
FOLLOW_RUN_HOURS=${FOLLOW_RUN_HOURS}
436458
EXPECTED_PROTOCOL_VERSION=${EXPECTED_PROTOCOL_VERSION}
437459
FORK_BLOCK=${FORK_BLOCK_NUMBER}
460+
FORK_HOURS_BACK=${FOLLOW_FORK_HOURS_BACK}
438461
BASELINE_START_BATCH=${BASELINE_START}
462+
MAINNET_TIP_AT_START=${MAINNET_TIP}
463+
MAINNET_BUNDLE_TIP_AT_START=${MAINNET_BUNDLE_TIP}
439464
EOF
440465
log_ok " wrote $FOLLOW_ENV (SHADOW_REPORT_START=$REPORT_START, FOLLOW_RUN_HOURS=$FOLLOW_RUN_HOURS)"
441466
fi

tests/shadow-testing/follow/scripts/generate-catchup-report.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,51 @@ def main():
5959
anvil_end = records[-1].get("anvil_block", 0)
6060

6161
bundles_created = last_summary.get("max_bundle", 0) - first_summary.get("max_bundle", 0)
62-
bundles_finalized = last_summary.get("finalized_bundles", 0) - first_summary.get("finalized_bundles", 0)
62+
# max_finalized_bundle is a monotonic watermark (shadow fork's finalized
63+
# bundle index); finalized_bundles per record is an interval count, so the
64+
# watermark delta is the robust total.
65+
bundles_finalized = last_summary.get("max_finalized_bundle", 0) - first_summary.get("max_finalized_bundle", 0)
66+
67+
# Run metadata (written by 10-follow-up.sh) — used to split the report into
68+
# a catch-up phase (fork finalized index climbing to the mainnet bundle tip
69+
# recorded at run start) and a steady-state phase (everything after).
70+
run_env = {}
71+
env_path = os.environ.get(
72+
"SHADOW_FOLLOW_ENV",
73+
os.path.join(os.path.dirname(LOG_FILE), "follow-run.env"),
74+
)
75+
if os.path.exists(env_path):
76+
for line in open(env_path):
77+
if "=" in line:
78+
k, v = line.strip().split("=", 1)
79+
run_env[k] = v
80+
bundle_tip_at_start = run_env.get("MAINNET_BUNDLE_TIP_AT_START")
81+
bundle_tip_at_start = int(bundle_tip_at_start) if bundle_tip_at_start else None
82+
fork_hours_back = run_env.get("FORK_HOURS_BACK")
83+
84+
catchup_end_idx = None
85+
if bundle_tip_at_start is not None:
86+
for i, r in enumerate(records):
87+
if r["summary"].get("max_finalized_bundle", 0) >= bundle_tip_at_start:
88+
catchup_end_idx = i
89+
break
90+
91+
def phase_stats(recs):
92+
"""Return (hours, bundles_created_delta, bundles_finalized_sum, avg proof times)."""
93+
if not recs:
94+
return 0.0, 0, 0, None, None, None
95+
hours = (datetime.fromisoformat(recs[-1]["timestamp"]) - datetime.fromisoformat(recs[0]["timestamp"])).total_seconds() / 3600
96+
created = recs[-1]["summary"].get("max_bundle", 0) - recs[0]["summary"].get("max_bundle", 0)
97+
finalized = recs[-1]["summary"].get("max_finalized_bundle", 0) - recs[0]["summary"].get("max_finalized_bundle", 0)
98+
99+
def mean(key):
100+
vals = [r["summary"][key] for r in recs if r["summary"].get(key)]
101+
return round(sum(vals) / len(vals)) if vals else None
102+
103+
return hours, created, finalized, mean("avg_chunk_proof_time"), mean("avg_batch_proof_time"), mean("avg_bundle_proof_time")
104+
105+
catchup_stats = phase_stats(records[: catchup_end_idx + 1]) if catchup_end_idx is not None else None
106+
steady_stats = phase_stats(records[catchup_end_idx + 1 :]) if catchup_end_idx is not None else None
63107

64108
# Pull average proof times from the latest record
65109
avg_chunk = last_summary.get("avg_chunk_proof_time")
@@ -92,8 +136,24 @@ def main():
92136
print("# 48-Hour Shadow Catch-Up Report")
93137
print(f"- Duration: {duration_hours:.2f} hours ({start.isoformat()} -> {end.isoformat()})")
94138
print(f"- Anvil blocks: {anvil_start} -> {anvil_end} ({anvil_end - anvil_start} blocks)")
139+
if fork_hours_back is not None:
140+
print(f"- Fork: {fork_hours_back}h back, mainnet bundle tip at start: {bundle_tip_at_start}")
95141
print()
96-
print("## Bundle Throughput")
142+
if catchup_stats is not None:
143+
ch, cc, cf, c_chunk, c_batch, c_bundle = catchup_stats
144+
sh, sc, sf, s_chunk, s_batch, s_bundle = steady_stats
145+
print("## Catch-Up Phase (backlog at fork)")
146+
print(f"- Duration to catch up: {ch:.2f} hours")
147+
print(f"- Bundles finalized during catch-up: {cf}")
148+
print(f"- Avg proof times (interval means): chunk {c_chunk}s / batch {c_batch}s / bundle {c_bundle}s")
149+
print()
150+
print("## Steady-State Phase (following mainnet)")
151+
print(f"- Duration: {sh:.2f} hours")
152+
print(f"- Bundles created on mainnet: {sc}")
153+
print(f"- Bundles finalized on fork: {sf}")
154+
print(f"- Avg proof times (interval means): chunk {s_chunk}s / batch {s_batch}s / bundle {s_bundle}s")
155+
print()
156+
print("## Bundle Throughput (whole window)")
97157
print(f"- Bundles created (max_bundle delta): {bundles_created}")
98158
print(f"- Bundles finalized: {bundles_finalized}")
99159
print(f"- Finalization / creation ratio: {(bundles_finalized / bundles_created * 100):.1f}%" if bundles_created else "- No bundles created")

0 commit comments

Comments
 (0)