Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions tools/exit_certificate/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,38 @@ All checks run regardless of individual failures. A combined error lists every f
- **Output:** `step-a-addresses.json` (`[]common.Address`), `step-a-failed-traces.json` (`[]common.Hash`)
- **Option:** `continueOnTraceError=true` skips failed traces instead of aborting.

### Step B β€” EOA balance checking
### Step B β€” EOA balance checking + ERC-20 detection

Three phases:
Three sub-steps: B1, B2, B3. Running `--step b` executes all three.

#### Step B1 β€” EOA classification and balance fetching

1. `eth_getCode` β†’ classify each address as EOA or contract
2. `eth_getBalance` for all EOAs at `targetBlock`
3. `balanceOf(address)` per wrapped token Γ— per EOA (token list from LBT)

- **Output:** `step-b-eoa-balances.json` (`[]EOABalance`), `step-b-accumulated.json` (`[]AccumulatedBalance`), `step-b-contract-addresses.json` (`[]common.Address`)

#### Step B2 β€” ERC-20 detection in contracts

Probes each contract address with `totalSupply()` / `balanceOf(address(0))` to confirm the ERC-20 interface. For each detected ERC-20, calls `balanceOf(contractAddr)` on every tracked wrapped token and `eth_getBalance` to find which tracked tokens it holds.

- Holds β‰₯ 1 tracked token β†’ `DetectedERC20` (relevant)
- Holds none β†’ `DiscardedERC20` (irrelevant)

- **Output:** `step-b2-detected-erc20s.json` (`[]DetectedERC20`), `step-b2-discarded-erc20s.json` (`[]DiscardedERC20`)

#### Step B3 β€” Extra ERC-20 holder decomposition

Iterates over `options.extraErc20Contracts`. For each address:

- If Step B2 already populated `Holders` for it, copies those holders and marks `AlreadyFromB2=true` β€” no RPC call.
- Otherwise, calls `fetchTokenBalances` (one RPC batch of `balanceOf` for every EOA from Step A).

Skipped automatically when `options.extraErc20Contracts` is empty.

- **Output:** `step-b3-erc20-holders.json` (`[]ERC20HolderBreakdown`)

### Step C β€” SC-locked value

- **Formula:** `SC_locked = LBT_totalSupply βˆ’ accumulated_EOA_balances` per token.
Expand Down
30 changes: 26 additions & 4 deletions tools/exit_certificate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ cp parameters.json.example parameters.json
| `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. For each address the tool calls `balanceOf` for every EOA collected in Step A. Example: `["0xAbc...123", "0xDef...456"]`. |

### Important configuration notes

Expand Down Expand Up @@ -193,7 +194,7 @@ Runs all steps sequentially: CHECK β†’ 0 β†’ A β†’ B β†’ C β†’ D β†’ E β†’ F β†’
| CHECK | Verify prerequisites | Checks Anvil, L1 RPC, network type (PP only), threshold = 1, no custom gas token. |
| 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 | Traces every L2 transaction via `debug_traceTransaction` and collects all addresses that touched state. |
| B | EOA balances | Classifies addresses as EOA vs contract; fetches ETH balance and every wrapped-token balance for each EOA at `targetBlock`. |
| 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. |
| D | Build certificate | Creates the `Certificate` with `BridgeExit` entries for every (EOA, token) pair 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. |
Expand Down Expand Up @@ -226,7 +227,7 @@ Spaces around commas are ignored. Execution stops at the first step that fails.
| Flag | Short | Default | Description |
| :--: | :---: | :-----: | :---------: |
| `--config` | `-c` | `parameters.json` | Path to the config file. |
| `--step` | β€” | `all` | Step(s) to run: `all`, a single step name, or a comma-separated list (e.g. `h,i,sign`). Valid names: `check`, `0`, `a`–`i`, `sign`, `submit`, `wait`. |
| `--step` | β€” | `all` | Step(s) to run: `all`, a single step name, or a comma-separated list (e.g. `h,i,sign`). Valid names: `check`, `0`, `a`, `a1`, `a2`, `b`, `b1`, `b2`, `b3`, `c`–`i`, `sign`, `submit`, `wait`. The aliases `a` and `b` expand to their sub-steps. |
| `--verbose` | β€” | `false` | Enable debug logging. Without this flag only `info`, `warn` and `error` messages are shown. |

## Pipeline steps
Expand Down Expand Up @@ -286,18 +287,39 @@ Scans all blocks from `l2StartBlock` to `targetBlock` and collects every address

**Output:** `step-a-addresses.json`

### Step B β€” EOA balance checking
### 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 across all EOAs (token list from LBT)
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.
Expand Down
10 changes: 9 additions & 1 deletion tools/exit_certificate/config-examples/zkevm-cardona.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"targetBlock": "LatestBlock",
"exitAddress": "0x0000000000000000000000000000000000001234",
"sovereignRollupAddr": "0xA13Ddb14437A8F34897131367ad3ca78416d6bCa",
"l1GlobalExitRootAddress": "<L1_GLOBAL_EXIT_ROOT_ADDRESS>",
"destinationNetwork": 0,
"signerConfig": {
"Method": "local",
Expand All @@ -21,12 +22,19 @@
"rpcDelayMs": 10,
"outputDir": "./output-cardona",
"l1StartBlock": 5157692,
"continueOnTraceError": false,
"abortOnGenesisBalance": true,
"ignoreUnclaimed": false,
"continueIfBalanceMismatch": false,
"extraErc20Contracts": [],
"bridgeServiceURL": "https://bridge-api.cardona.zkevm-rpc.com",
"bridgeServiceType": "zkevm",
"agglayerClient": {
"GRPC": {
"URL": "<AGGLAYER_GRPC_URL>"
}
}
},
"agglayerAdminURL": "<AGGLAYER_ADMIN_URL>",
"agglayerAdminToken": "<JWT>"
}
}
19 changes: 13 additions & 6 deletions tools/exit_certificate/config-examples/zkevm-mainnet.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
"l2RpcUrl": "https://zkevm-rpc.com/",
"l1BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe",
"l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe",
"l2NetworkId": 20,
"l2NetworkId": 1,
"targetBlock": "LatestBlock",
"exitAddress": "0x0000000000000000000000000000000000001234",
"exitAddress": "<PUBLIC_ADDRESS_TO_RECEIVE_SC_EXIT>",
"sovereignRollupAddr": "0x519E42c24163192Dca44CD3fBDCEBF6be9130987",
"l1GlobalExitRootAddress": "<L1_GLOBAL_EXIT_ROOT_ADDRESS>",
"destinationNetwork": 0,
"signerConfig": {
"Method": "local",
Expand All @@ -14,19 +16,24 @@
},
"options": {
"blockRange": 10000,
"stepAWindowSize": 10000,
"concurrencyLimit": 10,
"stepAWindowSize": 20000,
"concurrencyLimit": 200,
"rpcBatchSize": 99,
"rpcDelayMs": 10,
"outputDir": "./output-mainnet",
"l1StartBlock": 22431675,
"continueOnTraceError": false,
"abortOnGenesisBalance": true,
"ignoreUnclaimed": false,
"continueIfBalanceMismatch": false,
"extraErc20Contracts": ["0x4F9A0e7FD2Bf6067db6994CF12E4495Df938E6e9"],
"agglayerClient": {
"GRPC": {
"URL": "<AGGLAYER_GRPC_URL>"
"URL": "<AGGLAYER_GRPC_URL>",
"UseTLS": true
}
}
},
"agglayerAdminURL": "<AGGLAYER_ADMIN_URL>",
"agglayerAdminToken": "<JWT>"
}
}
14 changes: 13 additions & 1 deletion tools/exit_certificate/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ type Options struct {
// 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. Each contract is queried 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.
Expand Down Expand Up @@ -77,7 +81,7 @@ type Config struct {

const (
defaultBlockRange = 5000
defaultStepAWindowSize = 5000
defaultStepAWindowSize = 150000
defaultConcurrencyLimit = 20
defaultRPCBatchSize = 200
)
Expand Down Expand Up @@ -279,6 +283,13 @@ func mergeOptions(raw *rawOpts, configDir string) Options {
if raw.IgnoreUnclaimed != nil {
opts.IgnoreUnclaimed = *raw.IgnoreUnclaimed
}
if len(raw.ExtraERC20Contracts) > 0 {
addrs := make([]common.Address, 0, len(raw.ExtraERC20Contracts))
for _, s := range raw.ExtraERC20Contracts {
addrs = append(addrs, common.HexToAddress(s))
}
opts.ExtraERC20Contracts = addrs
}
if raw.BridgeServiceURL != "" {
opts.BridgeServiceURL = raw.BridgeServiceURL
}
Expand Down Expand Up @@ -320,6 +331,7 @@ type rawOpts struct {
ContinueOnTraceError *bool `json:"continueOnTraceError"`
ContinueIfBalanceMismatch *bool `json:"continueIfBalanceMismatch"`
IgnoreUnclaimed *bool `json:"ignoreUnclaimed"`
ExtraERC20Contracts []string `json:"extraErc20Contracts"`
BridgeServiceURL string `json:"bridgeServiceURL"`
BridgeServiceType string `json:"bridgeServiceType"`
}
Expand Down
2 changes: 1 addition & 1 deletion tools/exit_certificate/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func TestLoadConfig_DefaultOptions(t *testing.T) {
cfg, err := LoadConfig(path)
require.NoError(t, err)
require.Equal(t, 5000, cfg.Options.BlockRange)
require.Equal(t, 5000, cfg.Options.StepAWindowSize)
require.Equal(t, 150000, cfg.Options.StepAWindowSize)
require.Equal(t, 20, cfg.Options.ConcurrencyLimit)
require.Equal(t, 200, cfg.Options.RPCBatchSize)
require.Equal(t, 0, cfg.Options.RPCDelayMs)
Expand Down
12 changes: 6 additions & 6 deletions tools/exit_certificate/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,12 @@ func TestStepD_WithProductionLikeData(t *testing.T) {
stepC := &StepCResult{
SCLockedValues: []SCLockedValue{
{
WrappedTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
OriginNetwork: 0,
OriginTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
LBTBalance: "5000000000",
EOAAccumulated: "1000000000",
SCLockedBalance: "4000000000",
WrappedTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
OriginNetwork: 0,
OriginTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
LBTBalance: "5000000000",
EOAAccumulated: "1000000000",
PendingSCLockedBalance: "4000000000",
},
},
}
Expand Down
27 changes: 24 additions & 3 deletions tools/exit_certificate/parameters.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,35 @@
"targetBlock": "LatestBlock",
"exitAddress": "0x0000000000000000000000000000000000000001",
"destinationNetwork": 0,
"sovereignRollupAddr": "<SOVEREIGN_ROLLUP_ADDR>",
"l1GlobalExitRootAddress": "<L1_GLOBAL_EXIT_ROOT_ADDRESS>",
"options": {
"blockRange": 10000,
"stepAWindowSize": 150000,
"concurrencyLimit": 200,
"rpcBatchSize": 200,
"rpcDelayMs": 10,
"outputDir": "./output",
"l1StartBlock": 0
"l1StartBlock": 0,
"continueOnTraceError": false,
"abortOnGenesisBalance": true,
"ignoreUnclaimed": false,
"continueIfBalanceMismatch": false,
"extraErc20Contracts": [],
"bridgeServiceURL": "",
"bridgeServiceType": "aggkit",
"agglayerClient": {
"GRPC": {
"URL": "<AGGLAYER_GRPC_URL>",
"UseTLS": false
}
},
"agglayerAdminURL": "<AGGLAYER_ADMIN_URL>",
"agglayerAdminToken": "<JWT>"
},
"signerKeyPath": "/path/to/keystore.json",
"signerKeyPassword": "keystore-password"
"signerConfig": {
"Method": "local",
"Path": "/path/to/keystore.json",
"Password": "<PASSWORD>"
}
}
23 changes: 23 additions & 0 deletions tools/exit_certificate/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"math"
"net/http"
"net/url"
"strings"
"time"

"github.com/agglayer/aggkit/log"
Expand All @@ -22,6 +23,8 @@ const (
idleConnTimeoutSec = 90
httpTimeoutSec = 120
maxIdleConnsPerHost = 100
// eip1474RevertCode is the JSON-RPC error code for a contract revert per EIP-1474.
eip1474RevertCode = 3
)

// httpClient keeps a large per-host idle connection pool to avoid throttling
Expand Down Expand Up @@ -77,6 +80,16 @@ type RPCCall struct {
Params []any
}

// isRevertError returns true for errors that represent a contract revert β€”
// code 3 per EIP-1474, or any message containing "revert". These should not
// be retried because the same call will revert again.
func isRevertError(e *jsonRPCError) bool {
if e.Code == eip1474RevertCode {
return true
}
return strings.Contains(strings.ToLower(e.Message), "revert")
}

// batchRPC sends a batch of JSON-RPC calls in a single HTTP POST.
// Returns ordered results matching the input calls slice. Per-item RPC errors are retried
// up to retries times. Returns an error if any call still fails after all retries.
Expand All @@ -90,6 +103,7 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([]
for i := range pendingIdxs {
pendingIdxs[i] = i
}
var permanentlyFailed []int

for attempt := 1; attempt <= retries && len(pendingIdxs) > 0; attempt++ {
if ctx.Err() != nil {
Expand Down Expand Up @@ -149,6 +163,12 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([]
}
origIdx := pendingIdxs[localIdx]
if r.Error != nil {
if isRevertError(r.Error) {
log.Warnf("RPC call %s id=%d reverted (not retrying): [%d] %s",
calls[origIdx].Method, origIdx+1, r.Error.Code, r.Error.Message)
permanentlyFailed = append(permanentlyFailed, origIdx)
continue
}
log.Warnf("RPC error for %s id=%d (attempt %d/%d): [%d] %s",
calls[origIdx].Method, origIdx+1, attempt, retries, r.Error.Code, r.Error.Message)
nextPending = append(nextPending, origIdx)
Expand All @@ -159,6 +179,9 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([]
pendingIdxs = nextPending
}

if len(permanentlyFailed) > 0 {
return nil, fmt.Errorf("batchRPC: %d/%d calls reverted (not retried)", len(permanentlyFailed), len(calls))
}
if len(pendingIdxs) > 0 {
return nil, fmt.Errorf("batchRPC: %d/%d calls still failing after %d attempts",
len(pendingIdxs), len(calls), retries)
Expand Down
Loading
Loading