An independent challenger bot for the Vea optimistic bridge.
It watches Claimed events on a Vea outbox, independently verifies every claimed
state root against the Vea inbox snapshot on the origin chain (quorum reads over
multiple RPCs, at the finalized tag), and challenges any claim whose root does
not match — then drives the challenge through the canonical bridge to resolution
and withdraws the reward.
Built in Python from the Vea contracts and public docs only; it shares no code or
design with the TypeScript validator-cli.
- A verdict is only issued from inbox state that can no longer change:
- the finalized L2 block once its timestamp passes the claimed epoch's end, or
- the latest tag only once it is
sequencerDelayLimitpast the epoch end (before that a malicious sequencer could still backdate asaveSnapshot).
snapshots(epoch) == 0x0(no snapshot saved) makes any claim fraudulent.- Every configured RPC must agree on the snapshot value; splits raise a CRITICAL
alert instead of a (deposit-forfeiting) blind challenge — override with
VEA_CHALLENGE_ON_AMBIGUITY=true. - The Claim struct is reconstructed from events and kept byte-identical to the
on-chain
claimHashes[epoch]preimage, including hash-probing for the two transitions the contracts don't emit events for (theVerifiedwinner and the escape-hatch mutations).
uv sync
cp .env.example .env # add VEA_CHALLENGE_PRIVATE_KEY (funded EOA), pick a route
uv run vea-challenger scan # one read-only detection pass
uv run vea-challenger run # the real thing (challenges cost `deposit`!)
uv run vea-challenger --dry-run run # detect + alert, never spend
uv run vea-challenger status # tracked claims + balancesManual lifecycle commands (normally automated by run):
uv run vea-challenger challenge --epoch 246810
uv run vea-challenger resolve --epoch 246810 # sendSnapshot / execute L2->L1
uv run vea-challenger withdraw --epoch 246810Built-in: arb-sepolia-to-sepolia-testnet (default), arb-sepolia-to-sepolia-devnet,
arb-sepolia-to-chiado-testnet, arb-sepolia-to-chiado-devnet.
Custom routes (e.g. mainnet): copy a block from config.py into a TOML file and
set VEA_ROUTE_FILE. ArbToGnosis routes take the deposit in WETH on Gnosis
(the bot manages the allowance; keep the challenge account funded with WETH plus
native gas).
| Challenge deposit | outbox.deposit() — 1 ETH on the ArbToEth testnet route |
| Winning a challenge | +0.5 × deposit (plus your deposit back) |
| Losing a challenge | −1.0 × deposit (half burned, half to the claimer) |
| Resolution round trip | days (Arbitrum assertion confirmation), fully automated |
Use a plain EOA for both keys: several contract payouts use .send() with a
2300-gas stipend and will silently burn funds sent to contracts. Never overpay
the deposit (the bot always sends exactly deposit).
One honest, funded challenger is what keeps an optimistic bridge honest. If the
bot ever logs INSUFFICIENT FUNDS to challenge, treat it as an incident.
- State lives in a per-route SQLite file; the process is crash-safe and restartable at any point (tx intents are journaled before broadcast).
- Two key lanes: challenges never share a nonce sequence with slow resolution txs.
VEA_WEBHOOK_URLgets Slack/Discord-compatible JSON alerts;CRITICALmeans a fraud was found (or the bot cannot act — page someone).- Startup self-checks fail fast: chain-id match, genuine
finalizedtag support, localhashClaimparity against the deployed contract, funding.
Images are published to ghcr.io/kleros/vea-validator3 (linux/amd64) with
these tags: <version> (e.g. 0.1.0, no v), latest (the newest release
only), main (the tip of main, unreleased) and sha-<7 hex>. Run a release
pinned by digest, not latest: a tag can be moved to another image, a digest
cannot. Releases after 0.1.0 carry a signed build provenance; check it before
you move your pin:
gh attestation verify oci://ghcr.io/kleros/vea-validator3:<version> \
--repo kleros/vea-validator3 --source-ref refs/tags/v<version> \
--signer-workflow kleros/vea-validator3/.github/workflows/docker-publish.yml \
--deny-self-hosted-runners
docker buildx imagetools inspect ghcr.io/kleros/vea-validator3:<version> # its digest# state (SQLite db) persists under ./data on the host, mapped to /data in the container
# the container runs as uid 1000, so the host dir must be writable by that uid
# (if Docker creates ./data itself, it is root-owned and the bot cannot open its db)
mkdir -p data
sudo chown 1000:1000 data
IMAGE=ghcr.io/kleros/vea-validator3:<version>@sha256:<digest>
docker run -d --name vea-challenger \
--env-file .env \
-v "$(pwd)/data:/data" \
"$IMAGE" # runs `vea-challenger run`
# other subcommands override CMD, entrypoint is fixed to the `vea-challenger` binary
docker run --rm --env-file .env -v "$(pwd)/data:/data" "$IMAGE" status
docker run --rm --env-file .env -v "$(pwd)/data:/data" "$IMAGE" scan
docker run --rm --env-file .env -v "$(pwd)/data:/data" "$IMAGE" challenge --epoch 246810
# or build locally (the .dockerignore keeps .env*, data/ and keys out of the build)
docker build -t vea-challenger .Or use docker-compose, after the same mkdir/chown:
docker compose up -ddocker-compose.yml pins a release by digest and explains
how to move the pin.
VEA_DB_PATH defaults to /data/vea-challenger.db in the image. For a custom
route (VEA_ROUTE_FILE), also mount the TOML file under /data and point the
env var at it, e.g. -v $(pwd)/route.toml:/data/route.toml -e VEA_ROUTE_FILE=/data/route.toml.
Use one ./data directory per route. The image uses the same database path for
every route, and the database keys claims by epoch number only. Running two
routes on one ./data, or changing VEA_ROUTE on an existing one, mixes their
claims, scan cursors and tx journal. A scan cursor from another chain can make
the bot skip blocks and miss claims.
uv run pytest # unit tests (no network)
uv run pytest -m live # read-only smoke tests against public testnet RPCsDesign doc and threat analysis: docs/design.md.