Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

End-to-end tests

This document enumerates and summarizes the e2e tests. The tests are implemented using Bats framework and are assuming there is a running cluster to run them against. They are placed in the test/bats folder and divided into two major categories:

  • the ones that involve single L2 (pessimistic proof) and L1 network. They are found in the test/bats/pp folder.
  • the ones that involve two L2 (pessimistic proof) and single L1 network. They are found in the test/bats/pp-multi folder. Reusable helper functions are placed in the test/bats/helpers folder and they consist of sending and claiming bridge transactions, fetching proofs, sending transactions, querying contracts etc. Most of the functions rely on the cast command from Foundry.

Single L2 network

It involves single L2 network (and single L1 network), that are attached to the same agglayer.

The e2e environment must be able to start the docker compose stack, which requires enough host resources. If the host kills docker compose up (signal: killed) before the tests start, rerun the command on a host with more memory.

Transfer message

Bridges message from L1 to L2, by invoking bridgeMessage function on the bridge contract and then claiming once the global exit root is injected to the destination L2 network.

Native gas token deposit to WETH

Bridges and claims native token from L1 to L2, that is mapped to the WETH token on L2.

Test Bridge APIs workflow

Bridges the native token from L1 to L2 and then invokes the aggkit bridge service endpoints to verify they are working as expected: bridge_getBridges, bridge_l1InfoTreeIndexForBridge, bridge_injectedInfoAfterIndex and bridge_claimProof.

Custom gas token deposit L1 -> L2

Bridges custom gas token, that pre-exists on L1 and is mapped to a native token on L2, claims it on the L2 and asserts that the native token balance has increased when settled on L2.

Custom gas token withdrawal L2 -> L1

Bridges and claims native token on L2 network, that is pre-deployed and mapped to custom gas token on an L1 network and asserts that the gas token balance for the receiver address has increased after it got claimed on L1 network.

ERC20 token deposit L1 -> L2

It deploys the ERC20 token on the L1 and bridges and claims it to the L2. In this process of claiming the bridge, a token representation of given ERC20 token is automatically deployed on the L2.

Remove GER (invalid-GER recovery)

Exercises the remove-GER runbook end to end against the anvil-2chains env: inject an invalid GER on L2, confirm l2gersync blocks on it, run the remove_ger tool’s recovery flow (freeze bridge -> removeGlobalExitRoots -> category-specific claim correction -> restore bridge), and confirm l2gersync recovers automatically and resumes normal processing. Implemented in test/e2e/removeger_test.go:

go test -v -run 'TestRemoveGER_(NoProblematicClaims|CategoryA|CategoryB1|CategoryB2)|TestGenerateInvalidGER' -timeout 60m ./test/e2e
  • TestRemoveGER_NoProblematicClaims — invalid GER with no problematic claims; recovery is just freeze/remove/restore.
  • TestRemoveGER_CategoryA — invalid GER used by a claim that would under-collateralize the bridge; recovery adds an unsetMultipleClaims step.
  • TestRemoveGER_CategoryB1 — invalid GER used by a claim with correct bridge content and index but a wrong GER; recovery adds a forceEmitDetailedClaimEvent step.
  • TestRemoveGER_CategoryB2 — invalid GER used by a claim with correct bridge content but a wrong index; recovery adds unset + set claims + force-emit steps.
  • TestGenerateInvalidGER — exercises the remove_ger tool’s generate subcommand (which crafts and injects a synthetic invalid GER via cast) as a standalone check of the generation path. This test drives cast send/cast call from the host (outside Docker) against the L2 RPC port published by the Anvil compose env; on a dev machine whose local foundry cast cannot open outbound connections to that Docker-published port (while the Go ethclient used elsewhere in the harness reaches it fine — a machine-local cast networking quirk, not an aggkit or test defect), the test detects this via a preflight probe and cleanly t.Skips rather than failing. CI installs cast fresh and reaches the compose network normally, so the test runs in full there.

Each of the four TestRemoveGER_* scenarios asserts, via GET /bridge/v1/sync-status’s l2_ger_info (see Bridge service component):

  1. l2gersync is genuinely stalled on the invalid GER’s insert block while the L2 chain head keeps advancing (assertL2GERSyncStalledAt);
  2. after the recovery tool’s removeGlobalExitRoots call, l2gersync’s last_processed_block catches up past the actual removal transaction’s block (waitForL2GERSyncCaughtUp, targeting the block number returned by remove_ger.ExecuteRecovery’s RecoveryResult.RemovalBlock, not a post-hoc chain-head read — an earlier iteration of this assertion targeted an overshot, post-hoc head read and could time out a few blocks short of a real, successful recovery);
  3. l2gersync is genuinely alive afterwards, via a fresh, valid L1->L2 bridge and claim (assertL2GERSyncStillAlive).

Complementary log-based detection (detectInvalidGERFromAggkitLogs) is kept alongside the /sync-status assertions.

CI matrix

.github/workflows/test-go-e2e.yml runs the remove-GER tests on anvil-2chains in three dedicated matrix groups, so each group gets an isolated compose stack and cannot leak mutated chain state into the default group. The anvil-2chains / default group’s regex explicitly excludes them (Go’s -run has no negation syntax, so the default group is enumerated as a positive, anchored regex instead):

Matrix group (env / group)Tests
anvil-2chains / removeger-fastTestRemoveGER_NoProblematicClaims, TestRemoveGER_CategoryA, TestGenerateInvalidGER
anvil-2chains / removeger-b1TestRemoveGER_CategoryB1
anvil-2chains / removeger-b2TestRemoveGER_CategoryB2
anvil-2chains / finality-knobsTestAggsenderIndependentL1FinalityKnobs
anvil-2chains / defaultEverything else (positive-regex list, remove-GER tests excluded)

Independent L1 finality knobs

TestAggsenderIndependentL1FinalityKnobs (test/e2e/finality_knobs_test.go) covers aggkit#1846/#1847 on anvil-2chains. It restarts aggkit-001 with [L1InfoTreeSync] BlockFinality = "LatestBlock" and [AggSender] BlockFinalityForL1InfoTree = "LatestBlock/-N" while [L1Multidownloader] stays at its FinalizedBlock default, bridges and claims L1 -> L2 twice inside the resulting window, and asserts on the aggkit-001 logs and the aggsender RPC that:

  • block finality misconfiguration is absent (the aggsender accepts the asymmetric knobs and starts);
  • is not yet under the selected L1 info root is present (a claim whose GER is not yet under the selected root trims the certificate instead of failing);
  • exists on L1 but cannot be proved against selected root is absent (no hard error on that claim);
  • a certificate whose ToBlock covers the claims reaches Settled (aggsender_getCertificateHeaderPerHeight).

N and the per-phase timeouts are derived at runtime from the L1 latest-to-finalized lag and block time (N = 3*lag + ceil(120s / blockTime)), so the test does not hardcode anvil timing. The original config is restored (and the service restarted) in t.Cleanup.

AGGKIT_E2E_ENV=anvil-2chains make test-e2e TEST_RUN='^TestAggsenderIndependentL1FinalityKnobs$'

Bridge service health check

TestBridgeServiceHealthSyncStatus (test/e2e/health_test.go) exercises issue #1689 on anvil-2chains: it polls GET / on aggkit-001’s bridge service until sync_status reaches "done", asserting on every observed response that the HTTP status is always 200 (even while sync_status is still "pending") and that sync_status is one of the three documented values ("done"/"pending"/"error"); once settled, it checks that details reports at least one configured component with no error, then confirms GET /health serves the identical handler (same always-200 contract, same sync_status). See Bridge service component.

AGGKIT_E2E_ENV=anvil-2chains make test-e2e TEST_RUN='^TestBridgeServiceHealthSyncStatus$'

Bridge service sync status for all syncers

TestBridgeServiceSyncStatusAllSyncers (test/e2e/sync_status_all_syncers_test.go) exercises issue #1861 on anvil-2chains: for aggkit-001’s bridge service (and, since this env is multi-chain, the L2B bridge service too), it reads GET /bridge/v1/config to learn which of l1infotreesync, bridge L1, bridge L2 and l2gersync are configured, hardcodes that both claim syncers are always present (/config has no field for them), and polls GET /bridge/v1/sync-status until every entry matches that running/absent shape with no error. It then sends an L1 -> L2A bridge (without claiming) to force new blocks on the shared L1 chain and on L2A, and asserts that l1_info_tree_info and claim_l1_info‘s last_processed_block strictly advance on both bridge services (both track L1), and that L2A’s claim_l2_info does too (its own chain gets the new block); L2B’s claim_l2_info is only asserted to never go backwards, since nothing in this run mines a block on L2B’s own chain. Finally it polls GET /health until its details agree with the settled /sync-status entries’ is_active/is_halted and asserts sync_status is never "error". The halted path is covered by TestBridgeServiceReportsL1InfoTreeHalt (l1infotreesync package) and the bridgeservice unit test matrix, not here. See Bridge service component.

AGGKIT_E2E_ENV=anvil-2chains make test-e2e TEST_RUN='^TestBridgeServiceSyncStatusAllSyncers$'

Two L2 networks

It involves two L2 networks (and single L1 network), that are attached to the same agglayer.

Test L2 to L2 bridge

It bridges native tokens from L1 to both L2 networks and claims them. Afterwards, it bridges from L2 (PP2) to L2 (PP1) network and claims it on the destination network.

Bridge tracker: non-bridge emitter check

TestBridgeTrackerNotABridge (test/e2e/proxy_tracker_test.go) exercises BridgeEventSource’s fail-closed emitter-address check (issue #1751) on a multi-chain env with aggkit-proxy’s tracker enabled: it sends a transaction to test/contracts/bridgeeventimpostor (BridgeEventImpostor.sol), a throwaway contract whose only purpose is to emit a log with the exact same topic0 as the real bridge contract’s BridgeEvent, but from a non-bridge address. The tracker resolves the real, canonical L1 bridge address through the bridge service finder (BridgeServiceFinder/RollupManagerAddr), so the impostor log’s address never matches it; FindBridge finds no matching log and returns the permanent ErrBridgeTxNotABridge, which the tracker surfaces as a terminal tracking_status: "error" with bridge_status staying null. See TestBridgeTrackerL1ToL2 for the happy-path counterpart in the same file, and Bridge Tracker component for the address-check rule this exercises.

The impostor contract is compiled by test/contracts/compile.sh and bound into Go by test/contracts/bind.sh (gen bridgeeventimpostor) like every other test-only contract in test/contracts/.

go test -v -run 'TestBridgeTrackerNotABridge' -timeout 5m ./test/e2e

aggkit-proxy: error redaction (#1845)

TestProxyTrackerRedactsURLs (test/e2e/proxy_tracker_redaction_test.go) proves that the bridge tracker’s client-facing error strings never leak an internal URL, host or port, even though the underlying error legitimately contains one. It rewrites network 2’s RPCURLs/BridgeURLs entries in aggkit-proxy-001’s bind-mounted config to two distinct, DNS-unresolvable .invalid hostnames (a fast NXDOMAIN rather than a connect-timeout stall), restarts the service, and sends a bridge to network 2. It then polls the tracker until the tx-level error or a step’s error is populated and asserts that every description string, and the raw response body, contains neither :// nor either .invalid hostname — while at least one description contains <redacted-url> or <redacted-host>, proving a URL-bearing failure path actually ran rather than the test passing vacuously. The original config is restored (and the service restarted) in t.Cleanup. See Bridge Tracker component and ErrorStep for the redaction rule this exercises.

go test -v -run 'TestProxyTrackerRedactsURLs' -timeout 5m ./test/e2e

aggkit-proxy: gated auto-registration (#1855)

TestProxyAutoRegisterNewNetworks (test/e2e/proxy_autoregister_test.go) covers [BridgeServiceFinder] AutoRegisterNewNetworks on a second aggkit-proxy-002 instance that runs with it set to false (config/aggkit-proxy/aggkit-proxy-noautoreg.toml), alongside aggkit-proxy-001 which keeps the true default. It attaches a third rollup (network 3) to the L1 rollup manager — as an anvil-impersonated admin account, since its private key is unavailable, every on-chain write is sent as an unsigned eth_sendTransaction built from the generated ABI rather than through bind.TransactOpts — advertising network 1’s real, healthy bridge service so a reachable backend is available. It then asserts that aggkit-proxy-001 starts serving network 3 live, without a restart, while aggkit-proxy-002 keeps answering 404 for it and instead lists it under pending_networks in GET /tracker/v1/health (network id, rollup address, a non-zero block number and a non-empty reason); once aggkit-proxy-002 is restarted, network 3 is served there too and pending_networks is absent again. See Bridge Tracker component for the frozen-set/activation rules this exercises.

go test -v -run 'TestProxyAutoRegisterNewNetworks' -timeout 5m ./test/e2e

CI matrix

Both tests above run in their own dedicated .github/workflows/test-go-e2e.yml matrix group, anvil-2chains / proxy-tracker-stateful, separate from the bridge group: they rewrite aggkit-proxy’s bind-mounted config and restart it, and mutate L1 rollup-manager state, so they must not share a compose stack (or share L1 chain state) with the default bridge tests.

- env: anvil-2chains
  group: proxy-tracker-stateful
  run: "^(TestProxyAutoRegisterNewNetworks|TestProxyTrackerRedactsURLs)$"