exit_certificate

package
v0.11.0-rc9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 7, 2026 License: Apache-2.0, MIT Imports: 45 Imported by: 0

README

exit-certificate

Generate exit certificates for a chain migration — scans L2 state, computes balances, and builds a certificate that bridges all value back to L1.

Overview

What it does: The exit-certificate CLI scans an L2 chain from genesis to a target block, discovers all addresses with value, and produces an agglayer Certificate containing BridgeExit entries that transfer every balance (ETH + wrapped tokens) to the destination network. The certificate uses the native agglayer types directly — no conversion step is needed before submission.

When to use it: Use when an aggchain needs to exit the Agglayer ecosystem. The tool ensures all value on the L2 is accounted for and packaged into a single certificate.

Requirements

The chain being deprecated must meet all of the following conditions for the tool to produce a valid certificate. The first two and the last one are verified automatically by Step CHECK; the others are operational prerequisites you must ensure yourself.

  • The network must be Pessimistic Proof (PP). FEP (Finality by Execution Proof) chains are not supported. Step CHECK queries AGGCHAINTYPE() and aborts if the network is FEP.
  • The committee threshold must be 1. Exactly one committee member must be required to approve certificates. Step CHECK queries the multisig threshold and aborts if it is greater than 1.
  • The network must have settled at least one certificate. Step H derives the PreviousLocalExitRoot from the agglayer's settled_ler, treating a missing value (no certificate ever settled) as zero. In practice a never-settled chain still cannot be exited: the AET-11 verification requires the L2 bridge LER at the target block to equal the settled LER, and the bridge's LER (the root of its deposit tree, non-zero even when empty) never matches that zero fallback.
  • The network's sequencer must be stopped. Halt the sequencer before running the tool so that no new bridges (or other state changes) are produced while the certificate is being built. New activity after the target block would not be reflected in the certificate.
  • Every L2→L1 bridge exit up to the target block must be settled by the agglayer. Halting the sequencer is not sufficient: an ordinary bridge withdrawal made before the halt advances the L2 local exit root, and if no certificate settling it has been finalized, the tool cannot produce a certificate from that snapshot (the L2 LER no longer matches the agglayer's settled_ler). In practice: keep the aggsender running after the halt until the last certificate settles, and only then run the tool. Step CHECK verifies this (check 9, AET-11) and Step 0 re-verifies it on its resolved target block, aborting with an actionable error before the expensive scan/replay phases; Step H re-checks the same condition at the end. In single-step mode past Step 0 (--step g2, …) Step H remains the final safety net.

Known limitations

  • No unclaimed L1→L2 bridges are allowed. Every bridge towards L2 must be claimed before starting the process. Outstanding (unclaimed) deposits must be claimed first; otherwise the generated certificate will not reflect them correctly.
  • SetClaim and UpdatedUnsetGlobalIndexHashChain events are not supported. Transactions that emit these events on the bridge contract (see contracts) are not detected or accounted for. Value associated with these flows may be missing from the generated certificate.

Quick start

# Build from the repo root — the binary is written to target/exit_certificate
make build-exit_certificate

# Create your config from the example
cp tools/exit_certificate/parameters.json.example parameters.json

# Edit parameters.json with your RPC URLs, bridge address, etc.
# Then run the tool
./target/exit_certificate --config parameters.json

There are also ready-to-use config files for the zkEVM networks in config-examples/ (zkevm-cardona.toml, zkevm-mainnet.toml). Copy the one that matches your chain and fill in the fields documented in config-examples/README.md:

# Use a prepared zkEVM config as a starting point
cp tools/exit_certificate/config-examples/zkevm-mainnet.toml parameters.toml

# Edit parameters.toml (l1RpcUrl, exitAddress, signerConfig, etc.), then run
./target/exit_certificate --config parameters.toml

Building

From the repo root, using the top-level Makefile (binary is written to target/exit_certificate):

make build-exit_certificate

Alternatively, build directly with go from tools/exit_certificate/:

go build -o exit-certificate ./cmd

Config file

The tool uses a standalone config file in JSON or TOML format — the format is selected by the file extension (.toml is parsed as TOML, anything else as JSON). Copy the example and fill in your values:

# JSON
cp parameters.json.example parameters.json

# or TOML
cp parameters.toml.example parameters.toml

The field names are identical in both formats. Pass whichever you created with --config.

Note: parameters.json, parameters.toml and the output/ directory are git-ignored — they are not committed to the repository.

Config fields
Field Required Description
l2RpcUrl Yes L2 JSON-RPC endpoint. Step A requires debug_accountRange (an archive node exposing the debug namespace, to query state at targetBlock); without it Step A fails.
l1RpcUrl Yes* L1 JSON-RPC endpoint. Required by Step E (unclaimed deposit detection) and Step I (L1InfoTreeLeafCount). Without it Step E is silently skipped and Step I fails — the resulting certificate will be incomplete.
l2BridgeAddress Yes L2 bridge contract address.
l1BridgeAddress No L1 bridge contract address. Defaults to l2BridgeAddress. Verified on-chain by Step CHECK (networkID()==0 plus the aggchainbase/rollupManager bridge-address cross-checks).
l2NetworkId No L2 network ID. Defaults to 1.
targetBlock No Target block for state capture. Accepts a decimal number ("21000000"), hex ("0x1406f40"), or a finality keyword: "LatestBlock", "FinalizedBlock", "SafeBlock", "PendingBlock". An optional negative offset can be appended (e.g. "LatestBlock/-10" = ten blocks before latest). Omitting the field or setting it to "" defaults to "LatestBlock". The keyword is resolved to a concrete block number at the start of Step 0 and saved to step-0-l2_target_block.json. All subsequent steps use that fixed number.
exitAddress Yes* Address that receives SC-locked value exits on destinationNetwork. Must be an address whose private key you control, and must not be the zero address (0x00…00) — LoadConfig rejects both an empty value and the zero address, since these funds can only be recovered by signing from this address. A multisig (e.g. a Gnosis Safe) is strongly recommended over a single EOA, so that recovering these funds does not depend on a single private key. Not required with options.skipSCLockedValue: true (the SC-locked funds are then left behind and no exit targets this address); a non-empty value must still be valid hex.
destinationNetwork No Destination network for bridge exits. Defaults to 0 (L1).
sovereignRollupAddr Yes* Address of the aggchainbase contract on L1. Required by Step CHECK (network type and threshold verification).
l1GlobalExitRootAddress Yes* Address of PolygonZkEVMGlobalExitRootV2 on L1. Required by Step I to fetch L1InfoTreeLeafCount.
rollupManagerAddress No Address of the PolygonRollupManager (AgglayerManager) contract on L1. Used by Step WAIT to confirm the certificate's L1 settlement (VerifyBatchesTrustedAggregator). When unset it is resolved on-chain from sovereignRollupAddr.rollupManager().
signerConfig No Signer configuration object for Step SIGN. Same format as aggsender's AggsenderPrivateKey. Example: {"Method": "local", "Path": "keystore.json", "Password": "pass"}.

*Required for specific steps: l1RpcUrl is required by Steps E and I; sovereignRollupAddr is required by Step CHECK; l1GlobalExitRootAddress is required by Step I. Without them those steps fail. exitAddress is required unless options.skipSCLockedValue: true.

Options
Field Default Description
blockRange 5000 Block range per eth_getLogs query (Steps 0, B, E).
concurrencyLimit 20 Max concurrent RPC requests.
rpcBatchSize 200 Max calls per JSON-RPC batch request.
rpcDelayMs 0 Delay between RPC batches (rate limiting).
outputDir ./output Directory for intermediate and final output files. Relative paths resolve from the config file directory.
l1StartBlock 0 L1 block to start scanning from (Step E).
l1EndBlock 0 Optional L1 cutoff block. When set (> 0), Step E scans L1 for unclaimed deposits only up to this block (and filters the bridge-service cross-check accordingly) and Step I starts its backward UpdateL1InfoTreeV2 scan from it. This prevents L1 deposits submitted after the L2 snapshot from blocking the pipeline (AET-03): pick a block at or after the moment the sequencer was stopped. 0 (default) means no cutoff — the current latest L1 block is used. A value below l1StartBlock is rejected at config load; a value beyond the current L1 head is rejected when the step runs (it is almost surely a misconfiguration, e.g. an L2 block number).
l2StartBlock 0 Sanity guard: Step A errors if targetBlock is below this value. The state dump reads the trie at targetBlock and the Transfer-log scan always starts at genesis.
agglayerAdminURL "" Agglayer admin RPC endpoint. Required for Step F in agglayer mode (Step F errors if it runs without this set). Not needed when useAgglayerAdminToStepFCheck: false (offline LBT mode).
agglayerAdminToken "" Optional bearer token for authenticating requests to agglayerAdminURL. Leave empty when the admin endpoint is unauthenticated; set it only when the endpoint is protected (e.g. behind Google Cloud IAP).
agglayerClient {} Agglayer gRPC client config (same as aggsender's agglayer.ClientConfig). Set at least agglayerClient.GRPC.URL. Required for Steps H, SUBMIT, and WAIT.
useAgglayerAdminToStepFCheck true Selects the Step F comparison source. When true (default), Step F queries the agglayer admin API (admin_getTokenBalance) and does a three-way check (LBT == agglayer == certificate; requires agglayerAdminURL). When false, it skips the agglayer query and instead compares the LBT (Step 0) totals against the certificate bridge-exit sums offline (no agglayerAdminURL needed; skipped only if no LBT data exists).
ignoreGenesisBalance false When false (default), Step B aborts if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set true to downgrade it to a warning, only for Kurtosis or test environments.
nativeSCLockedFromContracts true When true (default), Step C computes the native token's SC-locked value from the actual ETH balances held by contract accounts (summed at targetBlock, excluding the L2 bridge reserve) instead of LBT − EOA_accumulated. That formula underflows on chains with a native genesis premint, clamping to 0 and silently dropping contract-held ETH from the certificate. Wrapped tokens are unaffected. Set to false to fall back to the LBT − EOA derivation. On premint chains combine with genesisPrefundETHWei so the Step F comparison also accounts for the premint.
skipSCLockedValue false When true, Step D omits the per-token SC-locked exits (the PendingSCLockedBalanceexitAddress entries) from the certificate: those funds are intentionally left behind on the L2, and exitAddress is no longer required. Step C still runs — step-c-sc-locked-values.json records what is left behind — and Step F discounts the omitted amounts from the LBT and agglayer balances before comparing (the check stays strict equality; each token's discount is recorded as skippedSCLockedAmount in step-f-checks.json, and the cap budget excludes the omitted funds). Holder-bridge exits (Step C's HolderBridges) are unaffected. In single-step mode --step f then requires step-c-sc-locked-values.json (run Step C first).
ignoreBalanceMismatch false When true, Step F does not abort the pipeline on token balance mismatches. Instead it produces a capped certificate (step-f-capped-certificate.json) where each token's bridge exits are trimmed so their per-token sum equals the budget min(agglayer, lbt). The allocation order is controlled by capMode — the default "none" forbids trimming, so combine with "amount" or "appearance". See Step F for details.
capMode "none" Selects how Step F allocates each token's cap budget when it needs to trim exits. "none" (default) forbids capping entirely: Step F fails if any exit would have to be trimmed — including the genesis pre-fund trim, so genesisPrefundETHWei requires a trimming mode. "amount" serves the smallest-amount exits first, so the largest holders are the first to be capped/dropped once the budget runs out; "appearance" serves exits in the order they appear. In both trimming modes the surviving exits are emitted in their original order. Any other value is rejected by LoadConfig.
genesisPrefundETHWei "" Optional amount of native token (in Wei, as a decimal string) pre-funded at genesis. Those funds sit in accounts — and therefore in the certificate's bridge exits — without a matching agglayer deposit, so Step F subtracts this value from the native-token certificate sum before comparing it against the agglayer balance and the LBT (which only count genuinely bridged funds), logging the certificate total, the pre-fund and the difference. The cap budget stays min(agglayer, lbt), and the Step 0 LBT and Step C SC-locked totals are untouched. The pre-funded amount has no agglayer collateral, so even when the checks match Step F emits step-f-capped-certificate.json trimming the native exits to that budget (see Step F). Validated by LoadConfig (non-negative base-10 integer). When set, Step B additionally verifies that the declared value equals the detected genesis ETH preload total and errors on mismatch — this error is not suppressed by ignoreGenesisBalance (a wrong declaration would make the Step F subtraction silently wrong). Example: 100000 ETH = "100000000000000000000000".
ignoreUnclaimed false When true, Step E detects and logs unclaimed deposits but leaves the certificate unchanged. When false (default), any unclaimed asset deposit causes the pipeline to error.
bridgeServiceURL "" Base URL of the bridge service REST API. When set, Step E cross-checks its unclaimed deposit set against the bridge service and returns an error on any discrepancy.
bridgeServiceType "aggkit" Bridge service API flavour. "aggkit" uses GET /bridge/v1/bridges (aggkit bridge service); "zkevm" uses GET /pending-bridges (zkevm-bridge-service).
extraErc20Contracts [] Optional list of ERC-20 contract addresses to decompose into individual holder balances in Step B3. Step A includes these contracts in its Transfer-log scan so even passive holders (no ETH/nonce/code) are discovered; Step B3 then calls balanceOf for every EOA collected in Step A. Example: ["0xAbc...123", "0xDef...456"].
ignoreUnsupportedL2Events false When true, the Step G lite syncer logs a warning and continues instead of aborting when it sees an L2 event that would invalidate a BridgeEvent-only reconstruction (SetSovereignTokenAddress, MigrateLegacyToken, RemoveLegacySovereignTokenAddress, BackwardLET, ForwardLET). The computed NewLocalExitRoot may then be incorrect — enable only to knowingly inspect such a chain.
ignoreLERMismatch false When true, the AET-11 unsettled-bridge-exits verification (Step CHECK check 9, the Step 0 guard, and Step H's LER cross-check) logs a warning and continues instead of aborting when the L2 bridge's LER at the target block does not match the agglayer's settled_ler. The certificate then chains from a PrevLocalExitRoot that does not cover every emitted bridge exit, so the agglayer will most likely reject it — enable only to knowingly inspect such a snapshot. Step H's pending-certificate guard is unaffected.
verifyNewLocalExitRootUsingShadowFork true Selects the Step G2 mode. When true (default), Step G2 spins up an Anvil shadow-fork, replays every bridge exit against the real bridge contract, recovers the on-chain metadata, and verifies the off-chain leaf encoding against the contract's getRoot() (requires anvil in $PATH). When false, Step G2 skips Anvil — much faster, but it trusts the off-chain leaf encoding/metadata. In both modes the certificate keeps its deterministic exit order and the NewLocalExitRoot is the lite exit tree root in that order. See Step G for details.
Important configuration notes

l1RpcUrl — required in practice

Although marked optional, l1RpcUrl is needed for Step E (unclaimed deposit detection) and Step I (L1InfoTreeLeafCount). In a real exit scenario you should always set it. Without it, Step E is silently skipped and the certificate may be missing unclaimed L1→L2 deposits.

exitAddress — required, keep the private key

SC-locked value (tokens held in smart contracts) is bridged to exitAddress on the destination network. The field is mandatory: LoadConfig errors if it is missing or set to the zero address (0x00…00). Use an address whose private key you control — once the certificate is settled, those funds can only be recovered by signing transactions from that address. If the key is lost, the value is permanently inaccessible.

The exception is options.skipSCLockedValue: true: the SC-locked funds are then intentionally left behind on the L2 and no exit targets exitAddress, so the presence and zero-address checks are skipped (a non-empty value must still be valid hex).

For this reason, a multisig wallet (e.g. a Gnosis Safe) is strongly recommended over a single EOA. Because these funds can only ever be recovered by signing from exitAddress, spreading control across several signers removes the single point of failure: no single lost or compromised key can lock up or steal the exited value.

agglayerClient — required for Steps H, SUBMIT, and WAIT

Uses the same agglayer.ClientConfig struct as aggsender. At minimum provide the gRPC URL; unset fields default to the same values used by aggsender:

"agglayerClient": {
  "GRPC": {
    "URL": "localhost:50051"
  }
}

Full example with all fields (timeouts accept Go duration strings: "5s", "1m", etc.):

"agglayerClient": {
  "GRPC": {
    "URL": "localhost:50051",
    "RequestTimeout": "30s",
    "MinConnectTimeout": "5s",
    "UseTLS": false,
    "Retry": {
      "MaxAttempts": 3,
      "InitialBackoff": "1s",
      "MaxBackoff": "10s",
      "BackoffMultiplier": 2.0
    }
  }
}

signerConfig — required to sign and submit

Step SIGN requires a signer configuration. Use the same JSON format as aggsender's AggsenderPrivateKey:

"signerConfig": {
  "Method": "local",
  "Path": "/path/to/keystore.json",
  "Password": "your-password"
}

Without this field, Step SIGN is skipped when running the full pipeline and you will need to sign manually.

The example above uses a local keystore file. Other backends (GCP KMS, AWS KMS, etc.) are also supported. For the full list of signer methods and their configuration options see the go_signer repository.

Options to skip failing checks

Some options let you continue past conditions that would otherwise abort the pipeline. Use them with care:

Option Default When to change
ignoreGenesisBalance false Set to true only for Kurtosis or test environments where addresses are pre-funded at genesis. In production, a non-zero genesis balance indicates a misconfiguration, so leave it false to abort.
ignoreUnclaimed false Set to true to proceed even when unclaimed L1→L2 asset deposits are detected. The deposits are logged with a warning but the certificate is left unchanged. Only safe if you have independently verified the unclaimed deposits are negligible or already handled.

Commands

Run full pipeline
./target/exit_certificate --config parameters.json

Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → G → H → I → SIGN (if signerConfig is set).

This produces and signs the certificate but does not submit it. SUBMIT and WAIT are intentionally left out of the default pipeline — once you have reviewed the signed certificate, run them explicitly:

# Send the signed certificate to the agglayer
./target/exit_certificate --config parameters.json --step submit

# Wait for it to settle (on the agglayer and on L1)
./target/exit_certificate --config parameters.json --step wait
Step Name What it does
CHECK Verify prerequisites Checks Anvil, L1 RPC, network type (PP only), threshold = 1, no custom gas token, and no unsettled L2 bridge exits at the target block (L2 bridge LER vs the agglayer's settled_ler, AET-11).
0 Generate LBT Resolves targetBlock to a concrete block number, then scans NewWrappedToken events and fetches totalSupply per wrapped token at that block.
A Collect addresses Discovers every value-holding address from the final state (debug_accountRange state dump, for native-ETH holders and contracts) plus Transfer event logs per wrapped token (for token holders). Both sources always run and merge.
B EOA balances + ERC-20 detection B1: classifies addresses and fetches ETH/token balances for EOAs. B2: probes contracts for the ERC-20 interface and checks if they hold tracked wrapped tokens. B3: fetches holder breakdowns for extraErc20Contracts (skips any already processed by B2).
C SC-locked value Computes value locked in contracts: SC_locked = LBT_totalSupply − EOA_accumulated per token. With nativeSCLockedFromContracts=true the native token's SC-locked value is measured from actual contract ETH balances instead.
D Build certificate Creates the Certificate with BridgeExit entries for every (EOA, token) pair, every decomposed ERC-20 holder (Step C holder bridges), and every token with SC-locked value.
E Unclaimed deposits Scans L1 for unclaimed BridgeEvent deposits targeting L2. Message deposits (leaf_type=1) are saved to step-e-unclaimed-messages.json and never added to the certificate. Asset deposits (leaf_type=0): if none are found the certificate is passed through unchanged; if any are found and ignoreUnclaimed=true they are logged but the certificate remains unchanged; if found and ignoreUnclaimed=false the pipeline errors (Merkle proof support not yet implemented). Optionally cross-checks against a bridge service.
F Balance verification Three-way comparison (LBT, agglayer, certificate) per token. Aborts on mismatch by default; with ignoreBalanceMismatch=true produces a capped certificate (allocation set by capMode; the default "none" forbids trimming and fails instead). Whenever agglayerAdminURL is set it also dumps the agglayer LBT to step-f-agglayer-lbt.json. With useAgglayerAdminToStepFCheck=false it skips the agglayer comparison and does an offline LBT-vs-certificate comparison instead.
G NewLocalExitRoot G1: syncs the L2 bridge history from genesis up to targetBlock into a lite DB and resolves the shadow-fork block. G2: computes the NewLocalExitRoot — by default shadow-forks L2 via Anvil, replays all bridge exits, and reads the resulting root from the forked bridge contract (or computes it off-chain when verifyNewLocalExitRootUsingShadowFork=false).
H PreviousLocalExitRoot Fetches settled_ler from the agglayer gRPC to obtain the previous LER and the next certificate height.
I Assemble final cert Applies NewLocalExitRoot (G), PreviousLocalExitRoot + height (H), bridge exit metadata, and L1InfoTreeLeafCount (from the latest UpdateL1InfoTreeV2 event on L1).
SIGN Sign certificate Hashes the certificate and signs it with the configured keystore; wraps the signature in AggchainDataMultisig.
SUBMIT Send to agglayer Sends the signed certificate to the agglayer via gRPC. Not part of the default pipeline.
WAIT Wait for settlement Polls GetCertificateHeader every 5 s until the certificate is Settled or InError, then confirms the settlement on L1 (VerifyBatchesTrustedAggregator on the RollupManager + the accompanying UpdateL1InfoTree/UpdateL1InfoTreeV2 events). Not part of the default pipeline.

Steps SUBMIT and WAIT are not part of the default pipeline — they must be triggered explicitly.

Run one or more steps
# Single step
./target/exit_certificate --config parameters.json --step h

# Multiple steps (comma-separated, run in the given order)
./target/exit_certificate --config parameters.json --step h,i,sign
./target/exit_certificate --config parameters.json --step "sign, submit"

# Ranges (inclusive)
./target/exit_certificate --config parameters.json --step a-c     # a, b, c
./target/exit_certificate --config parameters.json --step g-      # g, h, i, sign (open range stops at sign)
./target/exit_certificate --config parameters.json --step 0-wait  # every step, including submit and wait

Each step reads its dependencies from the output directory (files written by prior steps). Spaces around commas are ignored. Execution stops at the first step that fails.

Ranges use from-to (inclusive). An open-ended from- runs through sign; submit and wait are left out of open ranges and must be named explicitly (e.g. 0-wait to run the entire flow end to end).

CLI flags
Flag Short Default Description
--config -c parameters.json Path to the config file.
--step all Step(s) to run. Accepts all; a single step name; a comma-separated list (e.g. h,i,sign); or a range from-to (inclusive, e.g. a-ca,b,c). An open-ended range from- runs through sign (e.g. g-g,h,i,sign); submit/wait are excluded from open ranges and must be named explicitly — use 0-wait to run every step. Valid names: check, 0, a, b/b1/b2/b3, cf, g/g1/g2, h, i, sign, submit, wait. The aliases b and g expand to their sub-steps and also work as range bounds.
--verbose false Enable debug logging. Without this flag only info, warn and error messages are shown.

Pipeline steps

Step CHECK — Verify prerequisites

Runs automatically as the first step of the full pipeline. Can also be run individually:

./target/exit_certificate --config parameters.json --step check

All checks run regardless of individual failures; a combined error lists every failed check.

  1. Anvil installedanvil must be in $PATH (required by Step G2 only when options.verifyNewLocalExitRootUsingShadowFork=true). Fails with a clear error pointing to getfoundry.sh if missing.
  2. L1 RPC reachable — dials l1RpcUrl and calls eth_blockNumber. Fails if not set or unreachable.
  3. l1BridgeAddress is the L1 bridge — calls networkID() on l1BridgeAddress over the L1 RPC and requires 0 (the L1/mainnet network). A wrong address (a typo, a non-bridge contract, or the l2BridgeAddress default not existing on L1) would make Step E silently miss every unclaimed L1→L2 deposit. The outcome is recorded in the result as l1BridgeAddressStatus; if check 2 failed it is unchecked and counted as a failure.
  4. L2 network ID matches bridge — calls NetworkID() on the L2 bridge contract and verifies it matches l2NetworkId in config.
  5. sovereignRollupAddr is set — required; fails if zero address.
  6. Network type is PP — queries AGGCHAINTYPE() on the aggchainbase contract at sovereignRollupAddr on L1. FEP is not supported. Only runs if checks 2 and 5 passed.
  7. Threshold is 1 + bridge addresses match — queries the multisig threshold. Fails if > 1. Also verifies l1BridgeAddress matches both aggchainbase.bridgeAddress() and the canonical rollupManager.bridgeAddress() (the mismatch error shows the correct address to configure). Logs all committee signers and their URLs. Only runs if checks 2 and 5 passed.
  8. No custom gas token — calls gasTokenAddress()/gasTokenNetwork() on the L2 bridge. Fails if a non-zero gas token is configured (not supported).
  9. No unsettled L2 bridge exits (AET-11) — requires agglayerClient.grpc.url (the same requirement Step H enforces later). Reads the L2 bridge's local exit root (getRoot()) at the target block — the block Step 0 already resolved (step-0-l2_target_block.json) when present, otherwise resolved from the config on the spot — and compares it against the agglayer's settled_ler (refusing to proceed on a pending certificate, like Step H). A mismatch means the target block's bridge LER and the settled LER have diverged — the pipeline aborts here, before the expensive scan (Steps A/B) and replay (Step G) phases, with instructions to pick a target block whose bridge LER matches the settled LER (wait for settlement or move the target block back when the bridge is ahead; move it forward when a certificate settled past it). Step 0 re-runs this same verification on its own resolved block. The outcome is recorded in the result as unsettledExitsStatus (plus the two compared roots, settledLER / l2BridgeLER). With ignoreLERMismatch=true the failure is logged as a warning and not counted (the result still records the real outcome).

Output: step-check-result.json

Step 0 — Generate LBT (Local Balance Tree)
Target block resolution

The targetBlock config field accepts a finality keyword, an optional offset, or a concrete block number. Step 0 resolves it to a uint64 before doing any work. Right after the resolution — and before the LBT scan — Step 0 re-runs the unsettled-bridge-exits verification (AET-11, same as Step CHECK's check 9) on the resolved block and aborts on mismatch (a warning instead with ignoreLERMismatch=true); the check is skipped with a warning when agglayerClient.grpc.url is not configured (Step CHECK reports that as a failure).

targetBlock value How it is resolved
"" or omitted Equivalent to "LatestBlock"
"LatestBlock" eth_getBlockByNumber("latest") on the L2 RPC
"FinalizedBlock" eth_getBlockByNumber("finalized") on the L2 RPC
"SafeBlock" eth_getBlockByNumber("safe") on the L2 RPC
"PendingBlock" eth_getBlockByNumber("pending") on the L2 RPC
"LatestBlock/-10" Latest block number minus 10
"21000000" / "0x1406f40" Used directly, no RPC call needed

The resolved number is written to step-0-l2_target_block.json and used as a fixed reference by all subsequent steps (A, B, G). When running individual steps the file must exist (produced by a prior Step 0 run).

Step 0 — LBT generation

After resolution, Step 0 scans the L2 bridge contract for NewWrappedToken events and fetches the totalSupply of each wrapped token at the resolved block. It also applies any SetSovereignTokenAddress overrides (remapped wrapped addresses), computes the unlocked native token balance, and checks for a WETH entry if the chain has a custom gas token.

Output: step-0-l2_target_block.json (resolved block number), step-0-lbt.json (LBT entries)

Step A — Collect addresses

Discovers every value-holding address at targetBlock from the final state plus token logs, instead of replaying the whole chain history with debug_traceTransaction. It combines two sources and merges them:

  1. State dump — walks the entire account trie at targetBlock via paginated debug_accountRange calls: every account with non-zero balance/nonce/code (all native-ETH holders and every contract) in O(#accounts). The node's debug_accountRange dialect (geth vs erigon/cdk-erigon) is auto-detected on the first page.
  2. Transfer event logs — scans eth_getLogs for each wrapped token (list from the Step 0 LBT) and each extra ERC-20 contract (options.extraErc20Contracts, deduplicated against the wrapped tokens) across [0, targetBlock], collecting the indexed from/to of every Transfer. This surfaces token-only EOAs that have no nonce/balance/code and therefore appear in neither a state dump nor a trace. The scan deliberately starts at block 0 (not l2StartBlock) so passive holders that received tokens early are not dropped. The extra ERC-20s are scanned here (not in Step B3) because B3 only probes balanceOf against the Step A address set — a passive holder of an extra token would otherwise never be discovered and their collateral share would flow to exitAddress instead.

Both sources always run and their results are merged: they cover complementary blind spots (the state dump cannot see token-only EOAs, which do not exist in the account trie; the Transfer logs cannot see accounts that never touched a wrapped token, i.e. native-ETH holders and contracts), so neither is sufficient on its own.

The state dump fails loudly (instead of returning a truncated or empty set) when debug_accountRange is unavailable, when the node keeps returning a non-empty pagination cursor past the page cap, or when it returns 0 accounts (e.g. a geth archive node without address preimages) — there is no fallback, since a run without the dump would silently omit every native-ETH holder and contract.

The zero address (0x000…000) is treated like any other account: a plain transfer(0x0, amount) is not a burn (the tokens remain in totalSupply) and native ETH can be sent there too (including genesis allocs), so its balances must be scanned and covered by the certificate for the totals to reconcile with the LBT. It is always added to the address set rather than trusting discovery: the state dump can miss it (nodes often lack the preimage for the zero key) and Transfer logs only surface it when a mint/burn happened, which would make the genesis-preload detection depend on unrelated token activity.

Output: step-a-addresses.json (the file consumed by later steps)

Step B — EOA balance checking + ERC-20 detection

Step B runs three sub-steps in sequence: B1, B2, and B3. Running --step b executes all three.

Step B1 — EOA classification and balance fetching

Classifies addresses as EOA vs contract, then queries ETH balance and every wrapped-token balance at targetBlock for all EOAs. The wrapped token list comes from the LBT data (Step 0).

Phases:

  1. eth_getCode to classify EOA vs contract
  2. eth_getBalance for all EOAs
  3. balanceOf calls per token × per EOA (token list from LBT)

Output: step-b-eoa-balances.json, step-b-accumulated.json, step-b-contract-addresses.json

Step B2 — ERC-20 detection in contracts

Probes every contract address for the ERC-20 interface by calling totalSupply(). For each ERC-20 found, checks whether it holds any of the tracked wrapped tokens:

  • Holds at least one tracked token → DetectedERC20 (relevant to the certificate)
  • Holds none → DiscardedERC20 (no tracked value locked inside)

Output: step-b2-detected-erc20s.json, step-b2-discarded-erc20s.json

Step B3 — Extra ERC-20 holder decomposition

Fetches the per-EOA token balance for each contract listed in options.extraErc20Contracts. These are ERC-20 contracts that should be decomposed into individual holder balances regardless of whether they were discovered by Step B2.

Skipped automatically when options.extraErc20Contracts is empty.

Output: step-b3-erc20-holders.json

Step C — SC-locked value extraction

Computes value locked in smart contracts using: SC_locked = LBT_totalSupply - accumulated_EOA_balances. Uses the LBT data (Step 0) for total supply per token. It also emits the per-holder bridge entries derived from the Step B3 ERC-20 decomposition, which Step D turns into exits back to each holder.

Native token on premint chains (nativeSCLockedFromContracts): on a chain where native ETH was minted at genesis directly into accounts, LBT − EOA underflows for the native token (the EOA balances include the premint but the LBT only measures bridge outflow), gets clamped to 0, and the ETH actually held by contracts silently disappears from the certificate. With options.nativeSCLockedFromContracts: true, Step C instead measures the native SC-locked value directly: it fetches eth_getBalance of every contract from Step B at targetBlock (excluding the L2 bridge, whose balance is the un-released reserve) and uses the sum as the native SC-locked value. Wrapped tokens keep the LBT − EOA formula. Note that with this option --step c is no longer a pure offline computation: it needs the L2 RPC, step-0-l2_target_block.json and step-b-contract-addresses.json (run Step B first).

Output: step-c-sc-locked-values.json, step-c-holder-bridges.json

Step D — Build exit certificate

Creates the agglayer Certificate with BridgeExit entries for:

  1. Every (EOA, token) pair with a non-zero balance → exits to the same address on the destination network
  2. Every holder of a decomposed ERC-20 contract (from Step C's holder bridges, i.e. the extraErc20Contracts / detected-vault breakdowns) → exits to the holder's address on the destination network
  3. Every token with SC-locked value → exits to exitAddress on the destination network. Omitted entirely with options.skipSCLockedValue: true — those funds are intentionally left behind on the L2 (Step C's output still records them, and Step F discounts them from the LBT/agglayer balances). The holder-bridge exits (2) are unaffected.

Output: step-d-exit-certificate.json

Step E — Unclaimed L1→L2 bridge deposits

Scans L1 for BridgeEvent events targeting the L2 and checks each deposit against isClaimed on the L2 bridge. Deposits are split by leaf type:

  • Message deposits (leaf_type=1) — never added to the certificate. Saved to step-e-unclaimed-messages.json for review.
  • Asset deposits (leaf_type=0) — three outcomes depending on what is found:
    • No unclaimed asset deposits → step completes, certificate passed through unchanged.
    • Unclaimed asset deposits found + ignoreUnclaimed=true → deposits are detected, amounts logged with a warning, certificate left unchanged.
    • Unclaimed asset deposits found + ignoreUnclaimed=false → pipeline errors. Adding unclaimed deposits to the certificate requires Merkle proofs which are not yet implemented.

When bridgeServiceURL is set, Step E compares its detected unclaimed set against the bridge service's pending-bridges and errors if the sets differ. Supports both aggkit (/bridge/v1/bridges) and zkevm-bridge-service (/pending-bridges) via bridgeServiceType.

The scan ends at options.l1EndBlock when configured (deposits made on L1 after that cutoff are ignored, both in the scan and in the bridge-service comparison), otherwise at the current latest L1 block.

Requires l1RpcUrl.

Output: step-e-unclaimed-bridges.json, step-e-unclaimed-messages.json (both always written), step-e-exit-certificate.json (only when the step produces a certificate — i.e. not on the ignoreUnclaimed=false abort path)

Step F — Agglayer token balance verification

Step F has two modes selected by options.useAgglayerAdminToStepFCheck (default true):

  • Agglayer mode (true): queries the agglayer admin API (admin_getTokenBalance) and performs a three-way comparison per token (requires agglayerAdminURL).
  • Offline mode (false): no agglayer query — performs a two-way LBT (Step 0) vs certificate comparison per token. No agglayerAdminURL needed; when no LBT data is available there is nothing to compare and the step is skipped. step-f-token-balances.json is not written in this mode.

The three-way comparison (agglayer mode):

Source What it represents
LBT (Step 0) totalSupply of the wrapped token at targetBlock — what the L2 contract holds
Agglayer What the agglayer believes is locked for this L2 network
Certificate Sum of all BridgeExit amounts for that token

All compared values must be equal. Each token is logged with ✅ or ❌:

✅ (network=1 addr=0xabc...): lbt=1000  certificate=1000  agglayer=1000
❌ MISMATCH (network=1 addr=0xdef...): lbt=800  certificate=1000  agglayer=900

If mismatches are found:

  • By default Step F aborts the pipeline with an error.
  • Set options.ignoreBalanceMismatch: true to continue instead. In that case the step produces step-f-capped-certificate.json, where each mismatched token's bridge exits are trimmed so their per-token sum equals the budget min(agglayer, lbt). Subsequent steps in the pipeline (G, H, I) automatically use this capped certificate.
    • options.capMode selects how each token's budget is allocated across its exits. "none" (the default) forbids capping: Step F fails if any exit would have to be trimmed, so combine ignoreBalanceMismatch=true with "amount" or "appearance". "amount" serves the smallest-amount exits first, so the largest holders are the first to be capped/dropped once the budget runs out; "appearance" serves exits in the order they appear. In both trimming modes the surviving exits are emitted in their original order.

When running Step G individually it also prefers step-f-capped-certificate.json over step-e-exit-certificate.json if the capped file exists (logged with ⚠️).

LBT data comes from step-0-lbt.json. In agglayer mode, if it is not available the comparison falls back to two-way (certificate vs agglayer only); in offline mode, missing LBT means there is nothing to compare and the step is skipped.

In agglayer mode agglayerAdminURL must be set (errors otherwise); offline mode needs no admin endpoint.

Genesis pre-fund adjustment: when options.genesisPrefundETHWei is set, Step F subtracts that Wei amount from the native-token certificate sum (floored at zero) before running either comparison, logging the certificate total, the declared pre-fund and the resulting difference. Native tokens minted at genesis sit in accounts — and therefore in the certificate's bridge exits — without a matching agglayer deposit, so this discounts them and lets the comparison balance against the genuinely bridged amount (the agglayer balance and the Step 0 LBT only count bridged funds). The cap budget stays min(agglayer, lbt), and the LBT written by Step 0 and the Step C SC-locked totals are left unchanged.

The pre-funded amount has no agglayer collateral, so it can never be bridged out: even when every check matches (thanks to the discount), Step F still produces step-f-capped-certificate.json trimming the native exits down to min(agglayer, lbt) — no ignoreBalanceMismatch needed. This trim requires a trimming capMode: with the default "none" Step F fails instead, so set capMode to "amount" (the large pre-funded holders absorb the trim) or "appearance".

Skipped SC-locked discount: with options.skipSCLockedValue: true the certificate omits the SC-locked exits (Step D), so Step F subtracts each token's PendingSCLockedBalance (Step C) from the LBT and agglayer amounts before comparing — the check remains strict equality against the smaller certificate, and the cap budget excludes the left-behind funds. The applied discount is recorded per token as skippedSCLockedAmount in step-f-checks.json (the persisted LBT/agglayer amounts are the adjusted ones). In single-step mode --step f then also reads step-c-sc-locked-values.json (run Step C first).

Agglayer LBT dump: whenever agglayerAdminURL is set, Step F queries admin_getTokenBalance once at the very start and writes the full raw response (the agglayer's local balance tree for l2NetworkId) to step-f-agglayer-lbt.json, regardless of the comparison mode. In agglayer mode this same response is reused for the comparison (no second RPC). The scripts/get-agglayer-lbt.sh helper fetches the same data manually.

Reads: step-d-exit-certificate.json, step-0-lbt.json, step-c-sc-locked-values.json (only with skipSCLockedValue=true)

Output: step-f-token-balances.json, step-f-checks.json, step-f-agglayer-lbt.json (only when agglayerAdminURL is set), step-f-capped-certificate.json (when mismatches exist and ignoreBalanceMismatch=true, or when genesisPrefundETHWei trims the native exits)

Step G — Compute NewLocalExitRoot

Split into G1 (sync the L2 bridge history from genesis up to the target block into a lite DB, resolving the shadow-fork block) and G2 (compute the new_local_exit_root). In both modes the certificate's bridge_exits keep their deterministic incoming order and the new_local_exit_root is the lite exit tree root built from them in that order (the order agglayer rebuilds the LER from), so the same on-chain state always produces the same certificate. By default (options.verifyNewLocalExitRootUsingShadowFork=true) G2 additionally replays every bridge_exit against a shadow-fork of the L2 chain via Anvil, recovers the on-chain metadata, and verifies the off-chain leaf encoding against the forked contract's getRoot() (using a copy of the exits sorted by the replayed deposit counts — the replay's tx ordering is non-deterministic and never leaks into the certificate). Set the option to false to skip Anvil — faster, but it trusts the off-chain leaf encoding/metadata.

Anvil is required in the default shadow-fork mode (anvil binary in $PATH); the off-chain mode (verifyNewLocalExitRootUsingShadowFork=false) needs no Anvil. When the certificate has no bridge exits, the canonical empty LER is used.

Reads: step-f-capped-certificate.json if it exists (produced by Step F when ignoreBalanceMismatch=true), otherwise step-e-exit-certificate.json.

Output:

  • G1: step-g1-shadow-fork-block.json (resolved shadow-fork block) and the lite syncer DB output/step-g1-l2bridgesyncerlite.sqlite.
  • G2: step-g-new-local-exit-root.json, step-g-reordered-certificate.json (the certificate Step I consumes — same deterministic exit order, metadata hashes set; the file name is historical) and step-g-l2bridgesyncerlite.sqlite (working copy of the G1 DB with the tree built); in shadow-fork mode also step-g-failed-exit.json (only on replay failure).
Step H — Fetch PreviousLocalExitRoot

Calls interop_getNetworkInfo on the agglayer JSON-RPC and reads the settled_ler for the L2 network. If no certificate has been settled yet, PreviousLocalExitRoot is zero.

It also cross-checks Step G's InitialLocalExitRoot (the L2 bridge LER at the target block) against settled_ler and aborts on mismatch: the target block contains bridge exits no settled certificate covers (exits made before the sequencer halt that the agglayer never settled), or a new certificate settled while the certificate was being generated. Re-running with the same target block fails identically — pick a target block whose bridge LER matches the agglayer settled LER: when the bridge is ahead, wait until the agglayer settles every bridge exit up to the target block or move the target block back to the settled state; when the settled LER is ahead, move the target block forward. In the full pipeline Step CHECK (check 9) and the Step 0 guard catch this before the expensive work; in single-step mode this check is the final safety net. With ignoreLERMismatch=true the mismatch is logged as a warning and the step continues (the pending-certificate guard is unaffected).

Requires agglayerClient.GRPC.URL in options.

Output: step-h-previous-local-exit-root.json

Step I — Assemble final certificate

Takes the certificate produced by Step G and applies:

  • NewLocalExitRoot from Step G
  • PreviousLocalExitRoot and certificate height from Step H
  • L1InfoTreeLeafCount — scans L1 backwards from options.l1EndBlock (or the latest L1 block when unset) for the most recent UpdateL1InfoTreeV2 event on the l1GlobalExitRootAddress contract. Requires l1RpcUrl and l1GlobalExitRootAddress in config.

Reads: step-g-reordered-certificate.json (run Step G first — there is no fallback to the Step E / Step F certificates, so the final certificate always matches the computed NewLocalExitRoot); plus step-g-new-local-exit-root.json and step-h-previous-local-exit-root.json.

Output: exit-certificate-final.json

Step SIGN — Sign the certificate

Signs exit-certificate-final.json with the configured keystore and writes exit-certificate-signed.json. The signature is embedded in AggchainData as an AggchainDataMultisig ECDSA entry.

Requires signerConfig in config (same format as aggsender's AggsenderPrivateKey). Skipped automatically in all mode when signerConfig is not set.

Reads: exit-certificate-final.json

Output: exit-certificate-signed.json

Step SUBMIT — Send certificate to agglayer

Sends exit-certificate-signed.json to the agglayer via gRPC and returns the certificate hash. Not part of the default pipeline — must be triggered with --step submit.

Before submitting, it:

  1. Checks for a pending certificate on the network (GetLatestPendingCertificateHeader). If one exists and is not closed, the step errors — you must wait for it to settle before submitting a new one.
  2. Captures the latest L1 block right before submission (eth_blockNumber on l1RpcUrl). This is recorded in the result and marks the L1 starting point from which Step WAIT looks for the certificate's L1 settlement.

Requires agglayerClient.GRPC.URL and l1RpcUrl in config.

Reads: exit-certificate-signed.json

Output: step-submit-result.json (certificateHash + l1LatestBlockBeforeSubmittingCertificate)

Step WAIT — Wait for certificate settlement

Polls the agglayer until the submitted certificate reaches a final state, then confirms the settlement on L1. Not part of the default pipeline — must be triggered with --step wait.

Two phases:

  1. Agglayer settlement — polls GetCertificateHeader by hash every 5 seconds until the submitted certificate is Settled (success) or InError (returns an error). Logs the settlement tx hash on success.
  2. L1 settlement confirmation — scans the RollupManager contract on L1 from l1LatestBlockBeforeSubmittingCertificate (from the submit result) to the finalized block for the VerifyBatchesTrustedAggregator event matching the rollupID (l2NetworkId) and the certificate's NewLocalExitRoot. The RollupManager address is rollupManagerAddress if set, otherwise resolved on-chain from sovereignRollupAddr.rollupManager(). It re-resolves the finalized block and re-scans every 5 seconds until found. In that same L1 block it then reads the last UpdateL1InfoTree and UpdateL1InfoTreeV2 events emitted by l1GlobalExitRootAddress (the global-exit-root update accompanying the settlement).

Requires agglayerClient.GRPC.URL, l1RpcUrl, and l1GlobalExitRootAddress in config, plus either rollupManagerAddress or sovereignRollupAddr to resolve the RollupManager.

Reads: step-submit-result.json (certificate hash + the captured pre-submission L1 block)

Output: step-wait-result.json (final status, settlement tx hash, the L1 VerifyBatchesTrustedAggregator block/tx, and the UpdateL1InfoTree / UpdateL1InfoTreeV2 events in that block)

Result

After the full flow completes (the certificate is built and signed, then SUBMIT and WAIT succeed):

  • The agglayer holds every bridge exit in the certificate. Once the certificate settles, the agglayer accounts for all of the certificate's bridge_exits — the value has been bridged out of the L2 and is ready to be claimed on the destination network.
  • The files needed to claim those bridges have been generated. Claiming each exit requires calling claimAsset on the bridge contract with Merkle proofs and the exit roots. The companion exit_certificate_claimer tool consumes the exit_certificate output and produces the parameters for each claimAsset call.

The output files the claimer needs are:

File Used for
exit-certificate-signed.json The signed certificate — source of each exit's originNetwork, originTokenAddress, destinationNetwork, destinationAddress, amount, metadata.
step-g-l2bridgesyncerlite.sqlite The L2 local exit tree — used to build the smtProofLocalExitRoot proof of each leaf against new_local_exit_root.
step-wait-result.json The WAIT step's L1 settlement record (VerifyBatchesTrustedAggregator + the UpdateL1InfoTree/UpdateL1InfoTreeV2 events) used to anchor the claim to the settled global exit root.

Testing

From the repository root:

go test ./tools/exit_certificate/...

Documentation

Index

Constants

View Source
const (
	// CapModeNone forbids capping: Step F fails if any bridge exit would have to be trimmed.
	CapModeNone = "none"
	// CapModeByAppearance allocates each token's cap budget to its exits in appearance order.
	CapModeByAppearance = "appearance"
	// CapModeByAmount allocates each token's cap budget to its smallest-amount exits first, so the
	// largest-amount exits are the first to be capped/dropped.
	CapModeByAmount = "amount"
)

Cap modes for Options.CapMode (how Step F trims exits when capping a certificate).

View Source
const (

	// BridgeServiceTypeAggkit selects the aggkit bridge service API (/bridge/v1/bridges).
	BridgeServiceTypeAggkit = "aggkit"
	// BridgeServiceTypeZkevm selects the zkevm-bridge-service API (/pending-bridges).
	BridgeServiceTypeZkevm = "zkevm"
)

Variables

This section is empty.

Functions

func MakeBridgeExit

func MakeBridgeExit(
	originNetwork uint32, originTokenAddress common.Address,
	destNetwork uint32, destAddress common.Address, amount *big.Int,
) *agglayertypes.BridgeExit

MakeBridgeExit creates a BridgeExit for an asset transfer. Exported for tests.

func Run

func Run(c *cli.Context) error

Run is the CLI entry point.

func RunStepI

func RunStepI(
	ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult,
) error

RunStepI assembles the final certificate by applying the NewLocalExitRoot from Step G, the PreviousLocalExitRoot from Step H, and the L1InfoTreeLeafCount from L1.

func RunStepSign

func RunStepSign(
	ctx context.Context, cfg *Config, cert *agglayertypes.Certificate,
) (*agglayertypes.Certificate, error)

RunStepSign signs the certificate with the configured keystore and sets AggchainData to an AggchainDataMultisig containing the ECDSA signature.

Types

type AccumulatedBalance

type AccumulatedBalance struct {
	WrappedTokenAddress common.Address `json:"wrappedTokenAddress"`
	OriginNetwork       uint32         `json:"originNetwork"`
	OriginTokenAddress  common.Address `json:"originTokenAddress"`
	TotalBalance        string         `json:"totalBalance"`
}

AccumulatedBalance holds the total balance across all EOAs for a single token.

type CertificateEntry

type CertificateEntry struct {
	DestinationNetwork uint32 `json:"destinationNetwork"`
	DestinationAddress string `json:"destinationAddress"`
	Amount             string `json:"amount"`
}

CertificateEntry is one bridge exit entry for a given token, used in mismatch reports.

type Config

type Config struct {
	L2RPCURL            string                          `json:"l2RpcUrl"`
	L1RPCURL            string                          `json:"l1RpcUrl"`
	L2BridgeAddress     common.Address                  `json:"l2BridgeAddress"`
	L1BridgeAddress     common.Address                  `json:"l1BridgeAddress"`
	L2NetworkID         uint32                          `json:"l2NetworkId"`
	TargetBlock         aggkittypes.BlockNumberFinality `json:"targetBlock"`
	ExitAddress         common.Address                  `json:"exitAddress"`
	DestinationNetwork  uint32                          `json:"destinationNetwork"`
	SovereignRollupAddr common.Address                  `json:"sovereignRollupAddr"`
	// L1GlobalExitRootAddress is the address of the PolygonZkEVMGlobalExitRootV2 contract on L1.
	// Required for Step I to fetch the L1InfoTreeLeafCount from UpdateL1InfoTreeV2 events.
	L1GlobalExitRootAddress common.Address `json:"l1GlobalExitRootAddress"`
	// RollupManagerAddress is the optional address of the PolygonRollupManager (AgglayerManager)
	// contract on L1. Used by Step WAIT to confirm the certificate was settled on L1 by scanning for
	// the VerifyBatchesTrustedAggregator event matching the rollupID and the certificate's exit root.
	// When unset it is resolved on-chain from SovereignRollupAddr.rollupManager() (PolygonConsensusBase).
	RollupManagerAddress common.Address           `json:"rollupManagerAddress"`
	Options              Options                  `json:"options"`
	SignerConfig         signertypes.SignerConfig `json:"-"`

	// ConfigPath is the path the config was loaded from, and ConfigSHA256 is the
	// hex sha256 of the exact on-disk bytes that produced this Config. Both are set
	// by LoadConfig and used by the startup traceability banner. Not serialized.
	ConfigPath   string `json:"-"`
	ConfigSHA256 string `json:"-"`
}

Config holds all parameters required by the exit certificate tool.

func LoadConfig

func LoadConfig(configPath string) (*Config, error)

LoadConfig reads and validates the config file. The format is selected by file extension: ".toml" is parsed as TOML, anything else (".json" or no extension) as JSON.

type DetectedERC20

type DetectedERC20 struct {
	Address              common.Address        `json:"address"`
	Name                 string                `json:"name,omitempty"`
	Symbol               string                `json:"symbol,omitempty"`
	TotalSupply          string                `json:"totalSupply"`
	WrappedTokenBalances []WrappedTokenBalance `json:"wrappedTokenBalances"`
}

DetectedERC20 holds an ERC-20 contract that holds at least one tracked wrapped token.

type DiscardedERC20

type DiscardedERC20 struct {
	Address     common.Address `json:"address"`
	Name        string         `json:"name,omitempty"`
	Symbol      string         `json:"symbol,omitempty"`
	TotalSupply string         `json:"totalSupply"`
}

DiscardedERC20 is an ERC-20 contract that holds none of the tracked wrapped tokens.

type EOABalance

type EOABalance struct {
	Address    common.Address    `json:"address"`
	ETHBalance string            `json:"ethBalance"`
	Tokens     []EOATokenBalance `json:"tokens"`
}

EOABalance holds all non-zero balances for a single EOA address.

type EOATokenBalance

type EOATokenBalance struct {
	WrappedTokenAddress common.Address `json:"wrappedTokenAddress"`
	OriginNetwork       uint32         `json:"originNetwork"`
	OriginTokenAddress  common.Address `json:"originTokenAddress"`
	Balance             string         `json:"balance"`
}

EOATokenBalance records a single token balance for an EOA.

type ERC20Holder

type ERC20Holder struct {
	Address common.Address `json:"address"`
	Balance string         `json:"balance"`
}

ERC20Holder is an (address, balance) pair produced by Step B2.

type ERC20HolderBreakdown

type ERC20HolderBreakdown struct {
	Address common.Address `json:"address"`
	Holders []ERC20Holder  `json:"holders"`
	// Detected is the collateral info from Step B2: which tracked wrapped tokens this
	// contract holds, plus its name/symbol/totalSupply. Nil when the contract was not
	// present in the B2 detected list (e.g. it holds no tracked wrapped tokens).
	Detected *DetectedERC20 `json:"detected,omitempty"`
}

ERC20HolderBreakdown holds the full holder decomposition for a single ERC-20 contract produced by Step B3.

type FailedBridgeExit

type FailedBridgeExit struct {
	Index              int    `json:"index"`
	Error              string `json:"error"`
	OriginNetwork      uint32 `json:"originNetwork"`
	OriginTokenAddress string `json:"originTokenAddress"`
	DestinationNetwork uint32 `json:"destinationNetwork"`
	DestinationAddress string `json:"destinationAddress"`
	Amount             string `json:"amount"`
	IsNative           bool   `json:"isNative"`
	L2TokenAddress     string `json:"l2TokenAddress"`
}

FailedBridgeExit records the bridge exit whose replay aborted Step G, persisted to step-g-failed-exit.json so the offending exit can be inspected after the run fails.

type HolderBridge

type HolderBridge struct {
	VaultAddress        common.Address `json:"vaultAddress"`
	WrappedTokenAddress common.Address `json:"wrappedTokenAddress"`
	OriginNetwork       uint32         `json:"originNetwork"`
	OriginTokenAddress  common.Address `json:"originTokenAddress"`
	HolderAddress       common.Address `json:"holderAddress"`
	Amount              string         `json:"amount"`
}

HolderBridge is an individual bridge exit for a holder of an ERC-20 vault/staking contract, representing their proportional share of the tracked wrapped tokens locked inside that contract. Produced by Step C from the Step B3 breakdown data.

type L1Deposit

type L1Deposit struct {
	LeafType           uint8          `json:"leafType"`
	OriginNetwork      uint32         `json:"originNetwork"`
	OriginAddress      common.Address `json:"originAddress"`
	DestinationNetwork uint32         `json:"destinationNetwork"`
	DestinationAddress common.Address `json:"destinationAddress"`
	Amount             *big.Int       `json:"amount"`
	Metadata           []byte         `json:"metadata"`
	DepositCount       uint32         `json:"depositCount"`
	BlockNumber        uint64         `json:"blockNumber"`
	TxHash             common.Hash    `json:"txHash"`
}

L1Deposit represents an L1 bridge deposit targeting the L2 chain.

type L1InfoTreeUpdate

type L1InfoTreeUpdate struct {
	MainnetExitRoot common.Hash `json:"mainnetExitRoot"`
	RollupExitRoot  common.Hash `json:"rollupExitRoot"`
	TxHash          common.Hash `json:"txHash"`
}

L1InfoTreeUpdate captures an UpdateL1InfoTree(bytes32 indexed mainnetExitRoot, bytes32 indexed rollupExitRoot) event from the L1 GlobalExitRoot contract.

type L1InfoTreeV2Update

type L1InfoTreeV2Update struct {
	CurrentL1InfoRoot common.Hash `json:"currentL1InfoRoot"`
	LeafCount         uint32      `json:"leafCount"`
	Blockhash         common.Hash `json:"blockhash"`
	MinTimestamp      uint64      `json:"minTimestamp"`
	TxHash            common.Hash `json:"txHash"`
}

L1InfoTreeV2Update captures an UpdateL1InfoTreeV2(bytes32 currentL1InfoRoot, uint32 indexed leafCount, uint256 blockhash, uint64 minTimestamp) event from the L1 GlobalExitRoot contract.

type LBTEntry

type LBTEntry struct {
	WrappedTokenAddress common.Address `json:"wrappedTokenAddress"`
	OriginNetwork       uint32         `json:"originNetwork"`
	OriginTokenAddress  common.Address `json:"originTokenAddress"`
	Balance             string         `json:"balance"`
	// LegacyTokens holds previous wrapped addresses (replaced via SetSovereignTokenAddress)
	// and their totalSupply at the target block. Populated only when an override was applied.
	LegacyTokens []LegacyToken `json:"legacyTokens,omitempty"`
}

LBTEntry is a single entry from the Local Balance Tree file exported by the getLBT tool.

func LoadLBTEntries

func LoadLBTEntries(lbtFilePath string) ([]LBTEntry, error)

LoadLBTEntries reads the full LBT JSON file.

type LegacyToken

type LegacyToken struct {
	Address common.Address `json:"address"`
	Balance string         `json:"balance"`
}

LegacyToken records a wrapped token address that was replaced by a SetSovereignTokenAddress override, along with its totalSupply at the target block.

type Options

type Options struct {
	BlockRange       int    `json:"blockRange"`
	ConcurrencyLimit int    `json:"concurrencyLimit"`
	RPCBatchSize     int    `json:"rpcBatchSize"`
	RPCDelayMs       int    `json:"rpcDelayMs"`
	OutputDir        string `json:"outputDir"`
	L1StartBlock     uint64 `json:"l1StartBlock"`
	// L1EndBlock is an optional L1 cutoff block for the L1 reads. When set (>0), Step E scans L1 for
	// unclaimed deposits only up to this block (and filters the bridge service cross-check
	// accordingly) and Step I starts its backward UpdateL1InfoTreeV2 scan from it, so L1 deposits
	// submitted after the L2 snapshot cannot block the pipeline (AET-03). Pick a block at or after
	// the moment the sequencer was stopped. 0 (the default) means no cutoff: the current latest L1
	// block is used (previous behaviour).
	L1EndBlock       uint64 `json:"l1EndBlock"`
	L2StartBlock     uint64 `json:"l2StartBlock"`
	AgglayerAdminURL string `json:"agglayerAdminURL"`
	// AgglayerAdminToken is an optional Bearer token for authenticating requests to agglayerAdminURL.
	// Required when the admin endpoint is protected by Google Cloud IAP.
	// Obtain it with: gcloud auth print-identity-token --impersonate-service-account=<SA>
	// --audiences=<AUDIENCE> --include-email
	AgglayerAdminToken string                `json:"agglayerAdminToken"`
	AgglayerClient     agglayer.ClientConfig `json:"agglayerClient"`
	// UseAgglayerAdminToStepFCheck, when true (the default), runs Step F: it queries the agglayer
	// admin API (admin_getTokenBalance) and verifies the per-token balances against the certificate
	// and LBT. When false, Step F is skipped entirely (no agglayer admin query, no balance check).
	UseAgglayerAdminToStepFCheck bool `json:"useAgglayerAdminToStepFCheck"`
	// IgnoreGenesisBalance, when true, suppresses the abort that fires when any EOA or contract has a
	// non-zero ETH balance at block 0 (a genesis preload that would inflate the exit certificate
	// totals): the check still runs and warns, but the run continues. Defaults to false (abort); set
	// to true only for Kurtosis or test environments.
	IgnoreGenesisBalance bool `json:"ignoreGenesisBalance"`
	// NativeSCLockedFromContracts, when true (the default), computes the native-token SC-locked value
	// in Step C from the actual ETH balances held by contract accounts (summed, excluding the L2
	// bridge) rather than from LBT − EOA_accumulated. That formula underflows on chains with a native
	// genesis premint, clamping to 0 and silently dropping contract-held ETH. Set to false to fall
	// back to the LBT − EOA derivation for the native token.
	NativeSCLockedFromContracts bool `json:"nativeSCLockedFromContracts"`
	// SkipSCLockedValue, when true, excludes the SC-locked funds from the certificate: Step D does not
	// create the per-token bridge exits that send each PendingSCLockedBalance (Step C) to exitAddress —
	// those funds are intentionally left behind. exitAddress is then no longer required. Step C still
	// runs (its output records what is left behind), and Step F discounts the omitted per-token amounts
	// from the LBT/agglayer budgets so the balance check still requires strict equality. Holder-bridge
	// exits (vault redistribution to real holders) are unaffected. Defaults to false.
	SkipSCLockedValue bool `json:"skipSCLockedValue"`
	// IgnoreBalanceMismatch suppresses the error returned by Step F when token balances
	// do not match. Set to true only when investigating discrepancies without blocking the pipeline.
	IgnoreBalanceMismatch bool `json:"ignoreBalanceMismatch"`
	// IgnoreUnclaimed skips adding unclaimed L1→L2 deposits to the certificate in Step E.
	// The step still detects and warns about any unclaimed deposits, but the certificate is left unchanged.
	IgnoreUnclaimed bool `json:"ignoreUnclaimed"`
	// ExtraERC20Contracts is an optional list of ERC-20 contract addresses whose token holders
	// are decomposed in Step B3. Step A includes these contracts in its Transfer-log scan so even
	// passive holders (no ETH/nonce/code) are discovered; Step B3 then queries each contract with
	// balanceOf for every EOA address collected in Step A.
	ExtraERC20Contracts []common.Address `json:"extraErc20Contracts,omitempty"`
	// BridgeServiceURL is the base URL of the bridge service REST API.
	// When set, Step E queries the bridge service for pending bridges targeting this L2 and returns an
	// error if any unclaimed deposits are found.
	// Aggkit example:  "http://127.0.0.1:32970"
	// zkevm example:   "http://127.0.0.1:33019"
	BridgeServiceURL string `json:"bridgeServiceURL"`
	// BridgeServiceType selects the bridge service API flavour: "aggkit" (default) or "zkevm".
	BridgeServiceType string `json:"bridgeServiceType"`
	// IgnoreUnsupportedL2Events, when true, makes the Step G lite syncer log a warning
	// and continue instead of aborting when it sees an L2 event that would invalidate a
	// BridgeEvent-only reconstruction (SetSovereignTokenAddress, MigrateLegacyToken,
	// RemoveLegacySovereignTokenAddress, BackwardLET, ForwardLET). The computed NewLocalExitRoot may
	// then be incorrect; enable only to inspect such a chain knowingly. Defaults to false.
	IgnoreUnsupportedL2Events bool `json:"ignoreUnsupportedL2Events"`
	// IgnoreLERMismatch, when true, downgrades the AET-11 unsettled-bridge-exits
	// verification — Step CHECK check 9, the Step 0 guard and Step H's LER cross-check — from an
	// abort to a warning: the pipeline proceeds even when the L2 bridge's LER at the target block
	// does not match the agglayer's settled LER. The resulting certificate chains from a
	// PrevLocalExitRoot that does not cover every emitted bridge exit, so the agglayer will most
	// likely reject it — enable only to inspect such a snapshot knowingly. It does not affect
	// Step H's pending-certificate guard. Defaults to false.
	IgnoreLERMismatch bool `json:"ignoreLERMismatch"`
	// VerifyNewLocalExitRootUsingShadowFork, when true (the default), makes Step G2 spin up the Anvil
	// shadow-fork, replay every bridge exit against the real bridge contract, and verify the computed
	// NewLocalExitRoot against the contract's getRoot(). When false, Step G2 computes the
	// NewLocalExitRoot purely off-chain from the lite exit tree (Step G1's genesis→fork bridges plus
	// the certificate's bridge exits) without launching Anvil — much faster, but it trusts the
	// off-chain leaf encoding (notably each exit's metadata) rather than verifying it on-chain.
	VerifyNewLocalExitRootUsingShadowFork bool `json:"verifyNewLocalExitRootUsingShadowFork"`
	// CapMode selects how bridge exits are trimmed when Step F needs to cap a certificate whose token
	// totals exceed the allowed budget. "none" (the default) forbids capping entirely: if any exit
	// would have to be trimmed, Step F fails instead. "amount" allocates each token's budget to its
	// smallest-amount exits first, so the largest holders are the first to be capped/dropped once the
	// budget runs out. "appearance" allocates to its exits in the order they appear, capping/dropping
	// the ones that no longer fit. In both trimming modes the surviving exits are emitted in their
	// original order.
	CapMode string `json:"capMode"`
	// GenesisPrefundETHWei is an optional amount of native token (in Wei, as a decimal string) that was
	// pre-funded at genesis. Those funds sit in accounts — and therefore in the certificate's bridge
	// exits — without a matching agglayer deposit, so Step F subtracts this value from the native-token
	// certificate sum before comparing it against the agglayer balance and the LBT (which only count
	// genuinely bridged funds), logging the certificate total, the pre-fund and the difference. The
	// pre-fund has no agglayer collateral and can never be bridged out: even when the checks match,
	// Step F produces a capped certificate trimming the native exits to min(agglayer, LBT) — this
	// requires a trimming CapMode ("amount" or "appearance"; the default "none" fails instead). The
	// Step 0 LBT and Step C SC-locked totals are untouched. Step B verifies the declared value against
	// the detected genesis ETH preload total. Empty means 0. Typical testnet value:
	// 100000 ETH = "100000000000000000000000".
	GenesisPrefundETHWei string `json:"genesisPrefundETHWei"`
}

Options holds tuning parameters for RPC parallelism and output.

type RPCCall

type RPCCall struct {
	Method string
	Params []any
}

RPCCall represents a single JSON-RPC method call.

type RPCExecutionError

type RPCExecutionError struct {
	Code    int
	Message string
	Data    string
}

RPCExecutionError is returned by singleRPC when the node returns an RPC-level error. Data holds the raw hex-encoded revert payload (e.g. ABI-encoded custom error).

func (*RPCExecutionError) Error

func (e *RPCExecutionError) Error() string

type SCLockedValue

type SCLockedValue struct {
	WrappedTokenAddress common.Address `json:"wrappedTokenAddress"`
	OriginNetwork       uint32         `json:"originNetwork"`
	OriginTokenAddress  common.Address `json:"originTokenAddress"`
	LBTBalance          string         `json:"lbtBalance"`
	EOAAccumulated      string         `json:"eoaAccumulated"`
	// ERC20HoldersCovered is the portion of SC-locked value distributed as individual
	// bridge exits to holders of ERC-20 vault contracts (from Step B3 breakdowns).
	// Empty when no breakdown applies to this token.
	ERC20HoldersCovered string `json:"erc20HoldersCovered,omitempty"`
	// TotalSCLockedBalance is the gross value locked in smart contracts: LBT - EOA.
	// It includes both the portion covered by ERC-20 holder bridges and the remainder.
	TotalSCLockedBalance string `json:"totalSCLockedBalance"`
	// PendingSCLockedBalance is the net SC-locked value that requires a bridge exit to
	// exitAddress: TotalSCLockedBalance − ERC20HoldersCovered.
	PendingSCLockedBalance string `json:"pendingSCLockedBalance"`
}

SCLockedValue holds the computed smart-contract-locked value for a single token.

type Step0Result

type Step0Result struct {
	TargetBlock uint64     `json:"targetBlock"`
	Entries     []LBTEntry `json:"entries"`
}

Step0Result holds the output of Step 0 (LBT generation).

func RunStep0

func RunStep0(ctx context.Context, cfg *Config) (*Step0Result, error)

RunStep0 generates the Local Balance Tree (LBT) by scanning the L2 bridge for NewWrappedToken events and fetching each token's totalSupply. This replaces the external getLBT tool.

type StepAResult

type StepAResult struct {
	Addresses     []common.Address `json:"addresses"`
	WrappedTokens []WrappedToken   `json:"-"`
}

StepAResult holds the output of Step A (address discovery via state dump + Transfer logs).

func RunStepA

func RunStepA(
	ctx context.Context, cfg *Config, targetBlock uint64, wrappedTokens []WrappedToken,
) (*StepAResult, error)

RunStepA collects every value-holding address at targetBlock without replaying the full transaction history via debug_traceTransaction.

It always combines two cheap sources and merges them, each covering the other's blind spot:

  1. a state-trie dump at targetBlock (debug_accountRange) — every account with non-zero balance/nonce/code (all native-ETH holders and every contract), and
  2. Transfer event logs (eth_getLogs) per wrapped token and per extra ERC-20 contract (cfg.Options.ExtraERC20Contracts) — every token holder, including token-only EOAs that never appear in a state dump or a trace (an ERC-20 transfer only mutates the token contract's storage, so the recipient account itself is never "touched"). Extra ERC-20 holders must be discovered here so Step B3 can probe their balances.

type StepB1Result

type StepB1Result struct {
	EOABalances       []EOABalance         `json:"eoaBalances"`
	Accumulated       []AccumulatedBalance `json:"accumulated"`
	ContractAddresses []common.Address     `json:"contractAddresses"`
}

StepB1Result holds the output produced exclusively by Step B1 (address classification and balance fetching). It does not include the ERC-20 detection data added by Step B2.

func RunStepB1

func RunStepB1(ctx context.Context, cfg *Config, targetBlock uint64, stepA *StepAResult) (*StepB1Result, error)

RunStepB1 classifies addresses as EOA vs contract, then collects ETH and wrapped token balances at targetBlock for all EOAs.

type StepB2Result

type StepB2Result struct {
	// DetectedERC20s are contracts that hold at least one tracked wrapped token.
	DetectedERC20s []DetectedERC20 `json:"detectedErc20s"`
	// DiscardedERC20s are contracts that responded to totalSupply() but hold none
	// of the tracked wrapped tokens and are therefore irrelevant to the certificate.
	DiscardedERC20s []DiscardedERC20 `json:"discardedErc20s,omitempty"`
}

StepB2Result holds the output of Step B2.

func RunStepB2

func RunStepB2(
	ctx context.Context, cfg *Config, targetBlock uint64,
	contractAddrs, eoaAddrs []common.Address,
	wrappedTokens []WrappedToken,
) (*StepB2Result, error)

RunStepB2 probes the contract addresses from Step B1 for the ERC-20 interface. For each contract that responds to totalSupply() with a non-zero value it checks whether it holds any of the tracked wrapped tokens:

  • holds at least one → DetectedERC20 (relevant to the certificate)
  • holds none → DiscardedERC20 (no tracked value locked inside)

RPC execution errors on totalSupply() calls are silently treated as "not ERC-20".

type StepB3Result

type StepB3Result struct {
	Breakdowns []ERC20HolderBreakdown `json:"breakdowns"`
}

StepB3Result holds the output of Step B3 (extra ERC-20 holder decomposition).

func RunStepB3

func RunStepB3(
	ctx context.Context, cfg *Config, targetBlock uint64,
	eoaAddrs []common.Address,
	b2Result *StepB2Result,
) (*StepB3Result, error)

RunStepB3 fetches the per-EOA token balance for each contract listed in cfg.Options.ExtraERC20Contracts. For each address, balanceOf is called for every EOA collected in Step A. Collateral info (tracked wrapped tokens held) is attached from the B2 detected list when available.

type StepBResult

type StepBResult struct {
	EOABalances           []EOABalance           `json:"eoaBalances"`
	Accumulated           []AccumulatedBalance   `json:"accumulated"`
	ContractAddresses     []common.Address       `json:"contractAddresses"`
	DetectedERC20s        []DetectedERC20        `json:"detectedErc20s,omitempty"`
	DiscardedERC20s       []DiscardedERC20       `json:"discardedErc20s,omitempty"`
	ERC20HolderBreakdowns []ERC20HolderBreakdown `json:"erc20HolderBreakdowns,omitempty"`
	// NativeContractLocked, when non-empty, is the total native ETH (wei, decimal) held by contract
	// accounts (bridge excluded). Set only when options.nativeSCLockedFromContracts is enabled; Step C
	// uses it as the native token's SC-locked value instead of the LBT − EOA formula.
	NativeContractLocked string `json:"-"`
}

StepBResult holds the combined output of Step B (B1 + B2 + B3).

func RunStepB

func RunStepB(ctx context.Context, cfg *Config, targetBlock uint64, stepA *StepAResult) (*StepBResult, error)

RunStepB runs Step B1, B2, and B3 and returns the combined result. B1 classifies addresses and collects balances; B2 detects ERC-20 contracts; B3 fetches holder breakdowns for the contracts listed in ExtraERC20Contracts.

type StepCResult

type StepCResult struct {
	SCLockedValues []SCLockedValue `json:"scLockedValues"`
	// HolderBridges are individual bridge exits for holders of ERC-20 vault contracts
	// whose breakdowns were provided by Step B3. These replace what would otherwise be a
	// single SC-locked exit to exitAddress for the portion of value they cover.
	HolderBridges []HolderBridge `json:"holderBridges,omitempty"`
}

StepCResult holds the output of Step C.

func RunStepC

func RunStepC(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResult, error)

RunStepC computes the value locked in smart contracts for each token.

Formula: SC_locked = LBT_totalSupply − accumulated_EOA_balances

When ERC20HolderBreakdowns are present (from Step B3), the portion of each token held by a vault/staking contract is distributed proportionally to its holders as individual HolderBridge exits instead of a single exit to exitAddress. The corresponding SC_locked value is reduced by the amount distributed.

type StepCheckResult

type StepCheckResult struct {
	AnvilInstalled  bool   `json:"anvilInstalled"`
	BridgeNetworkID uint32 `json:"bridgeNetworkID"`
	// L1BridgeAddressStatus records the l1BridgeAddress verification outcome: "ok" when networkID()
	// on the configured address returns 0 (the L1/mainnet network), "invalid (networkID()=N)" when
	// it hosts a non-L1 bridge, "error" when the call fails, "unchecked" when the L1 RPC is unavailable.
	L1BridgeAddressStatus string `json:"l1BridgeAddressStatus"`
	// RollupManagerBridgeAddress is the canonical L1 bridge address published by the RollupManager
	// (bridgeAddress()), recorded so a failed l1BridgeAddress check shows the correct value to use.
	RollupManagerBridgeAddress string   `json:"rollupManagerBridgeAddress,omitempty"`
	NetworkType                string   `json:"networkType"`
	Threshold                  uint64   `json:"threshold"`
	SignerCount                int      `json:"signerCount"`
	Signers                    []string `json:"signers,omitempty"`
	GasTokenAddress            string   `json:"gasTokenAddress,omitempty"`
	GasTokenNetwork            uint32   `json:"gasTokenNetwork,omitempty"`
	WETHToken                  string   `json:"wethToken,omitempty"`
	// UnsettledExitsStatus records the AET-11 unsettled-bridge-exits check outcome: "ok" when the
	// L2 bridge's LER at the target block equals the agglayer's settled LER, "unsettled exits at
	// block N" on mismatch, "error" when a query failed, "unchecked" when the agglayer gRPC URL is
	// not configured.
	UnsettledExitsStatus string `json:"unsettledExitsStatus,omitempty"`
	// SettledLER / L2BridgeLER are the two roots the AET-11 check compared: the agglayer's last
	// settled LER and the L2 bridge's getRoot() at the resolved target block.
	SettledLER  string `json:"settledLER,omitempty"`
	L2BridgeLER string `json:"l2BridgeLER,omitempty"`
}

StepCheckResult holds the output of Step CHECK (prerequisite verification).

func RunStepCheck

func RunStepCheck(ctx context.Context, cfg *Config) (*StepCheckResult, error)

RunStepCheck verifies prerequisites before running the pipeline:

  1. Anvil is installed ($PATH) — only required (counted as a failure) when Step G2 will use the shadow-fork (options.verifyNewLocalExitRootUsingShadowFork=true); otherwise its absence is just logged.
  2. L1 RPC is set and reachable.
  3. l1BridgeAddress is the L1 bridge: networkID() on it must return 0 (the L1/mainnet network).
  4. L2 network ID matches the bridge contract.
  5. sovereignRollupAddr is set.
  6. Network type is PP (FEP is not supported).
  7. Multisig threshold is 1, and l1BridgeAddress matches both aggchainbase.bridgeAddress() and the canonical rollupManager.bridgeAddress().
  8. No custom gas token is configured on the L2 bridge.
  9. No unsettled L2 bridge exits at the target block (AET-11): the L2 bridge's local exit root at the resolved target block must equal the agglayer's last settled LER, otherwise the certificate cannot be generated from that snapshot and Step H would abort after the expensive scan/replay phases.

All checks run regardless of individual failures. Returns a combined error listing every failed check.

type StepDResult

type StepDResult struct {
	Certificate *agglayertypes.Certificate `json:"certificate"`
}

StepDResult holds the output of Step D.

func RunStepD

func RunStepD(cfg *Config, stepB *StepBResult, stepC *StepCResult) (*StepDResult, error)

RunStepD builds the exit certificate from EOA balances (Step B) and SC-locked values (Step C).

Creates BridgeExit entries for:

  1. Every (EOA, token) pair with a non-zero balance
  2. Every holder of an ERC-20 vault/staking contract (from Step C HolderBridges)
  3. Every token with remaining SC-locked value, directed to exitAddress — omitted entirely when options.skipSCLockedValue is true (those funds are intentionally left behind; Step F discounts them from the LBT/agglayer budgets)

type StepEResult

type StepEResult struct {
	// UnclaimedBridges are unclaimed L1→L2 deposits with leaf_type=asset that were added
	// to the certificate as bridge exits and imported bridge exits.
	UnclaimedBridges []L1Deposit `json:"unclaimedBridges"`
	// UnclaimedMessages are unclaimed L1→L2 deposits with leaf_type=message. These are
	// logged as warnings but NOT added to the certificate (messages are not transferable assets).
	UnclaimedMessages []L1Deposit                `json:"unclaimedMessages,omitempty"`
	FinalCertificate  *agglayertypes.Certificate `json:"finalCertificate"`
}

StepEResult holds the output of Step E.

func RunStepE

func RunStepE(
	ctx context.Context, cfg *Config,
	certificate *agglayertypes.Certificate,
) (*StepEResult, error)

RunStepE finds unclaimed L1→L2 bridge deposits and reports them.

Approach:

  1. Scan L1 bridge for BridgeEvent where destinationNetwork == L2 networkId
  2. For each deposit, call isClaimed(depositCount, 0) on the L2 bridge contract
  3. Message deposits (leaf_type=1) are saved separately and never added to the certificate.
  4. Asset deposits (leaf_type=0): if none, the certificate is passed through unchanged. If ignoreUnclaimed=true, detected deposits are logged but the certificate is unchanged. If ignoreUnclaimed=false and any assets are found, the step errors (Merkle proofs not yet implemented).

type StepFResult

type StepFResult struct {
	AllMatch      bool                `json:"allMatch,omitempty"`
	TokenBalances json.RawMessage     `json:"tokenBalances,omitempty"`
	Checks        []TokenBalanceCheck `json:"checks,omitempty"`
	// CappedCertificate is set when mismatches were found and ignoreBalanceMismatch=true.
	// Bridge exits are trimmed so their per-token sum equals min(agglayer, lbt); the allocation
	// order is controlled by Options.CapMode (see capCertificateExits).
	CappedCertificate *agglayertypes.Certificate `json:"cappedCertificate,omitempty"`
}

StepFResult holds the output of Step F (agglayer token balance check).

func RunStepF

func RunStepF(
	ctx context.Context, cfg *Config,
	certificate *agglayertypes.Certificate,
	lbtEntries []LBTEntry,
	scLockedValues []SCLockedValue,
) (*StepFResult, error)

RunStepF verifies the certificate's per-token bridge-exit sums.

When useAgglayerAdminToStepFCheck is true (the default) it queries the agglayer admin API for token balances and performs a three-way comparison: LBT (Step 0 total supplies) == agglayer balance == sum of certificate bridge exits. agglayerAdminURL is required. lbtEntries may be nil, in which case it falls back to a two-way agglayer-vs-certificate comparison.

When useAgglayerAdminToStepFCheck is false it skips the agglayer admin query and instead runs an offline two-way comparison of the LBT (Step 0) totals against the certificate bridge-exit sums (see runStepFOfflineLBT). When no LBT data is available there is nothing to compare and the step is skipped.

scLockedValues carries the Step C per-token SC-locked amounts. It is only used when options.skipSCLockedValue is true: those amounts were intentionally omitted from the certificate by Step D, so they are discounted from the LBT/agglayer amounts before comparing (see discountSkippedSCLocked). With the option disabled the values are ignored.

type StepG1Result

type StepG1Result struct {
	// ShadowForkBlock is the L2 block Step G2 forks at — the resolved targetBlock up to which Step G1
	// lite-synced the bridge history.
	ShadowForkBlock uint64 `json:"shadowForkBlock"`
}

StepG1Result holds the output of Step G1: the L2 block at which Step G2 spins up its Anvil shadow-fork. Step G1 lite-syncs the L2 bridge history from genesis up to that block into the lite DB Step G2 reuses.

func RunStepG1

func RunStepG1(ctx context.Context, cfg *Config, targetBlock uint64) (*StepG1Result, error)

RunStepG1 persists the L2 bridge history Step G2 needs and resolves the block Step G2 forks at.

It syncs every L2 bridge from genesis up to targetBlock against the real L2 (cfg.L2RPCURL) with the lite bridge syncer, persisting them (no tree yet) so Step G2 can insert the replayed bridges on top and build the whole exit tree once. The full-history scan runs against the fast real L2 rather than the slow Anvil fork. The shadow-fork block is exactly the resolved targetBlock (the lite syncer fetches that range, no overshoot), so Anvil forks there aligned to the contract's state at that block.

type StepGResult

type StepGResult struct {
	// InitialLocalExitRoot is the LER read from the bridge contract at targetBlock,
	// before any bridge exits from the certificate are replayed.
	InitialLocalExitRoot common.Hash `json:"initialLocalExitRoot"`
	NewLocalExitRoot     common.Hash `json:"newLocalExitRoot"`
	BridgeExitCount      uint64      `json:"bridgeExitCount"`
	// BridgeExitMetadata holds each bridge exit's raw leaf metadata, in the same order as
	// Certificate.BridgeExits (in shadow-fork mode it is verified against the Metadata field of the
	// BridgeEvent the replay emitted for the exit). Step I applies these values to each
	// BridgeExit.Metadata before finalising the certificate.
	BridgeExitMetadata [][]byte `json:"bridgeExitMetadata,omitempty"`
}

StepGResult holds the output of Step G (NewLocalExitRoot calculation).

func RunStepG2

func RunStepG2(
	ctx context.Context, cfg *Config, forkBlock uint64, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry,
) (*StepGResult, error)

RunStepG2 computes Certificate.NewLocalExitRoot and the per-exit metadata.

In both modes the certificate's bridge exits keep their incoming order — deterministic since Steps D/E/F are — and the NewLocalExitRoot is the off-chain lite tree root built from the exits in that order (the order agglayer rebuilds the LER from), so the same on-chain state always yields the same certificate.

  • By default (options.verifyNewLocalExitRootUsingShadowFork is true — see defaultOptions) it spins up the Anvil shadow-fork, replays every exit against the real bridge contract, and recovers the on-chain metadata. The replay's tx ordering (and thus each exit's deposit count) is non-deterministic, so the contract's getRoot() is used only as a verification anchor: a lite tree built from the exits sorted by the replayed deposit counts must reproduce it, proving the off-chain leaf encoding matches the real exit tree before that same encoding is trusted for the certificate-order root.
  • When the option is false it skips Anvil and takes the off-chain lite tree root directly (trusting the off-chain leaf encoding — nothing to verify against).

forkBlock is the block resolved by Step G1. lbtEntries (Step 0 output) is used only by the shadow-fork path as a wrapped-token lookup so getTokenWrappedAddress RPC calls are avoided.

type StepHResult

type StepHResult struct {
	PreviousLocalExitRoot common.Hash `json:"previousLocalExitRoot"`
	// Height is the certificate height to use for the exit certificate (settled_height + 1,
	// or 0 if no certificate has been settled yet).
	Height uint64 `json:"height"`
}

StepHResult holds the output of Step H (PreviousLocalExitRoot and next height from agglayer).

func RunStepH

func RunStepH(ctx context.Context, cfg *Config, gResult *StepGResult) (*StepHResult, error)

RunStepH fetches the PreviousLocalExitRoot for the L2 network from the agglayer by calling GetNetworkInfo and reading the SettledLER field. gResult is the output of Step G; when provided, its InitialLocalExitRoot is compared against the agglayer's settled LER and an error is returned on mismatch.

type StepSubmitResult

type StepSubmitResult struct {
	CertificateHash common.Hash `json:"certificateHash"`
	// L1LatestBlockBeforeSubmittingCertificate is the latest L1 block number
	// captured right before the certificate was sent to the agglayer. It marks
	// the L1 starting point from which to look for the block where the agglayer
	// settles this certificate on L1 (e.g. for the exit certificate claimer).
	L1LatestBlockBeforeSubmittingCertificate uint64 `json:"l1LatestBlockBeforeSubmittingCertificate"`
}

StepSubmitResult holds the output of the SUBMIT step.

func RunStepSubmit

func RunStepSubmit(ctx context.Context, cfg *Config, cert *agglayertypes.Certificate) (*StepSubmitResult, error)

RunStepSubmit sends the signed certificate to the agglayer via gRPC and returns the certificate hash assigned by the agglayer. Requires options.agglayerClient.grpc.url.

type StepWaitResult

type StepWaitResult struct {
	CertificateHash  common.Hash                     `json:"certificateHash"`
	FinalStatus      agglayertypes.CertificateStatus `json:"finalStatus"`
	SettlementTxHash *common.Hash                    `json:"settlementTxHash,omitempty"`
	ElapsedSeconds   float64                         `json:"elapsedSeconds"`
	// VerifyBatchesL1Block and VerifyBatchesTxHash record where on L1 the RollupManager emitted
	// the VerifyBatchesTrustedAggregator event matching this certificate's rollupID and exit root
	// (the L1 block where the agglayer settled the certificate). Set only when rollupManagerAddress
	// is configured and the event was found.
	VerifyBatchesL1Block uint64       `json:"verifyBatchesL1Block,omitempty"`
	VerifyBatchesTxHash  *common.Hash `json:"verifyBatchesTxHash,omitempty"`
	// UpdateL1InfoTree and UpdateL1InfoTreeV2 are the last respective events emitted by the L1
	// GlobalExitRoot contract in VerifyBatchesL1Block (the L1 info tree update that accompanies the
	// certificate's settlement on L1).
	UpdateL1InfoTree   *L1InfoTreeUpdate   `json:"updateL1InfoTree,omitempty"`
	UpdateL1InfoTreeV2 *L1InfoTreeV2Update `json:"updateL1InfoTreeV2,omitempty"`
}

StepWaitResult holds the outcome of the WAIT step.

func RunStepWait

func RunStepWait(ctx context.Context, cfg *Config, submitResult *StepSubmitResult) (*StepWaitResult, error)

RunStepWait waits for the submitted certificate to reach a final state. It polls the agglayer for the certificate header by hash with GetCertificateHeader — which always returns the current status — until it is Settled (success) or InError (error).

Requires options.agglayerClient.grpc.url.

type TokenBalanceCheck

type TokenBalanceCheck struct {
	OriginNetwork      uint32             `json:"originNetwork"`
	OriginTokenAddress string             `json:"originTokenAddress"`
	LBTAmount          string             `json:"lbtAmount,omitempty"`
	CertificateAmount  string             `json:"certificateAmount"`
	AgglayerAmount     string             `json:"agglayerAmount"`
	Match              bool               `json:"match"`
	CertificateEntries []CertificateEntry `json:"certificateEntries,omitempty"`
	// SkippedSCLockedAmount is the SC-locked amount intentionally omitted from the certificate
	// (options.skipSCLockedValue) and discounted from this token's LBT/agglayer amounts before the
	// comparison (see discountSkippedSCLocked). Empty when no discount was applied.
	SkippedSCLockedAmount string `json:"skippedSCLockedAmount,omitempty"`
	// RemainingBalance is the cap budget for this token: min(LBT, agglayer), minus the omitted
	// SC-locked amount when options.skipSCLockedValue discounts it.
	// Not persisted to JSON; used internally by capCertificateExits.
	RemainingBalance *big.Int `json:"-"`
}

TokenBalanceCheck holds the three-way comparison between Step 0 (LBT), the certificate bridge exits, and the agglayer state for one token. LBTAmount is empty when LBT data was not available.

type WrappedToken

type WrappedToken struct {
	WrappedTokenAddress common.Address `json:"wrappedTokenAddress"`
	OriginNetwork       uint32         `json:"originNetwork"`
	OriginTokenAddress  common.Address `json:"originTokenAddress"`
}

WrappedToken describes a wrapped token deployed on L2 by the bridge contract.

func LBTEntriesToWrappedTokens

func LBTEntriesToWrappedTokens(entries []LBTEntry) []WrappedToken

LBTEntriesToWrappedTokens extracts the wrapped token list from LBT entries, filtering out entries with a zero wrappedTokenAddress (native token entry).

func LoadLBTWrappedTokens

func LoadLBTWrappedTokens(lbtFilePath string) ([]WrappedToken, error)

LoadLBTWrappedTokens reads the LBT JSON file and returns only non-zero-address tokens.

type WrappedTokenBalance

type WrappedTokenBalance struct {
	Token   WrappedToken `json:"token"`
	Balance string       `json:"balance"`
}

WrappedTokenBalance is the balance of a tracked wrapped token held by an ERC-20 contract.

Directories

Path Synopsis
scripts
agglayer_status command
Command agglayer_status prints the status and height of the latest agglayer certificate for an L2 network, using the same agglayer gRPC client as the exit_certificate tool.
Command agglayer_status prints the status and height of the latest agglayer certificate for an L2 network, using the same agglayer gRPC client as the exit_certificate tool.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL