Skip to content

Commit bae4fae

Browse files
joanestebanrclaude
andcommitted
feat(exit-certificate): remove options.addressDiscovery — Step A always runs state dump + Transfer logs
The two sources cover complementary blind spots (the dump for native-ETH holders and contracts, the Transfer logs for token holders, including passive ones), so any single-source run under-covers value. Step A now always runs both and merges them; the receipt-harvesting fallback and its options.stepAWindowSize tuning knob are removed along with the option — an unusable debug_accountRange now fails Step A instead of degrading silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7a889da commit bae4fae

13 files changed

Lines changed: 56 additions & 558 deletions

tools/exit_certificate/CLAUDE.md

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,10 @@ The zero address is **kept** like any other account: tokens transferred to `0x00
8383
`totalSupply` (a plain transfer is not a burn) and it can hold native ETH, so dropping it would
8484
leave the certificate unbalanced against the LBT.
8585

86-
- **Option:** `addressDiscovery``"auto"` (default: probe `debug_accountRange`, use dump + logs;
87-
else fall back to receipt harvesting via `eth_getBlockByNumber` + `eth_getTransactionReceipt` in
88-
`stepAWindowSize` windows, which misses internal value transfers), `"stateDump"`, `"logs"`, `"both"`.
89-
The sources cover complementary blind spots — the dump cannot see token-only EOAs (not in the
90-
account trie) and the logs cannot see accounts that never touched a wrapped token (native-ETH
91-
holders, contracts) — so the single-source modes are debugging aids: a run on one source
92-
under-covers value and Step F aborts on the LBT mismatch. Use `auto`/`both` for real runs.
86+
Both sources always run and merge — they cover complementary blind spots (the dump cannot see
87+
token-only EOAs, not in the account trie; the logs cannot see accounts that never touched a wrapped
88+
token: native-ETH holders, contracts). There is no fallback: an unusable dump fails Step A.
89+
9390
- **Output:** `step-a-addresses.json` (`[]common.Address`)
9491

9592
### Step B — EOA balance checking + ERC-20 detection
@@ -409,9 +406,9 @@ Defaults applied by `LoadConfig`:
409406
- **File chain with capping:** when `ignoreBalanceMismatch=true` produces a capped cert, the effective chain becomes: Step D → Step E → **Step F (capped)** → Step G → … Always check whether `step-f-capped-certificate.json` exists when investigating balance issues.
410407
- **`--verbose` flag:** the logger defaults to `info` level; pass `--verbose` to enable `debug` output.
411408
- **SC-locked value can be negative** when genesis state was pre-loaded or the LBT is stale — the genesis-balance guard (`ignoreGenesisBalance=false`, the default) catches this early.
412-
- **Step A needs an archive node.** `debug_accountRange` at `targetBlock` is the preferred source;
413-
when unavailable, the `auto` discovery mode falls back to receipt harvesting (which misses internal
414-
value transfers).
409+
- **Step A needs an archive node exposing `debug_accountRange`.** The state dump at `targetBlock`
410+
is mandatory (no fallback): without it Step A fails, since native-ETH holders and contracts would
411+
be silently omitted.
415412
- **Step G2 requires Anvil only in shadow-fork mode** (`options.verifyNewLocalExitRootUsingShadowFork=true`; `anvil` binary in `$PATH`, from the Foundry toolchain). The default off-chain mode needs no Anvil.
416413
- **FEP chains are not supported.** Only Pessimistic Proof certificates are generated.
417414
- **`SetClaim` and `UpdatedUnsetGlobalIndexHashChain` events are not handled** — value from those flows may be missing.

tools/exit_certificate/README.md

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ The field names are identical in both formats. Pass whichever you created with `
8484

8585
| Field | Required | Description |
8686
| :---: | :------: | :---------: |
87-
| `l2RpcUrl` | Yes | L2 JSON-RPC endpoint. Step A uses `debug_accountRange` when available (an archive node is required to query state at `targetBlock`); without it, the `auto` discovery mode falls back to receipt harvesting. |
87+
| `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. |
8888
| `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. |
8989
| `l2BridgeAddress` | Yes | L2 bridge contract address. |
9090
| `l1BridgeAddress` | No | L1 bridge contract address. Defaults to `l2BridgeAddress`. |
@@ -104,14 +104,12 @@ The field names are identical in both formats. Pass whichever you created with `
104104
| Field | Default | Description |
105105
| :---: | :-----: | :---------: |
106106
| `blockRange` | `5000` | Block range per `eth_getLogs` query (Steps 0, B, E). |
107-
| `stepAWindowSize` | `150000` | Number of blocks loaded into memory per iteration by Step A's receipt-harvesting fallback (used when `debug_accountRange` is unavailable in `auto` mode). |
108107
| `concurrencyLimit` | `20` | Max concurrent RPC requests. |
109108
| `rpcBatchSize` | `200` | Max calls per JSON-RPC batch request. |
110109
| `rpcDelayMs` | `0` | Delay between RPC batches (rate limiting). |
111110
| `outputDir` | `./output` | Directory for intermediate and final output files. Relative paths resolve from the config file directory. |
112111
| `l1StartBlock` | `0` | L1 block to start scanning from (Step E). |
113-
| `l2StartBlock` | `0` | L2 block to start scanning from (Step A's receipt-harvesting fallback only; the state dump reads the trie at `targetBlock` and the Transfer-log scan always starts at genesis). |
114-
| `addressDiscovery` | `"auto"` | Step A address-discovery strategy: `"auto"` (probe `debug_accountRange` and use state dump + Transfer logs, else fall back to receipt harvesting + Transfer logs), `"stateDump"`, `"logs"`, or `"both"`. The two sources cover complementary blind spots, so the single-source modes are for debugging only: `"stateDump"` misses token-only EOAs and `"logs"` misses native-ETH holders and contracts (see [Step A](#step-a--collect-addresses)). Unknown values fall back to `"auto"` with a warning. |
112+
| `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. |
115113
| `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). |
116114
| `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). |
117115
| `agglayerClient` | `{}` | Agglayer gRPC client config (same as aggsender's `agglayer.ClientConfig`). Set at least `agglayerClient.GRPC.URL`. Required for Steps H, SUBMIT, and WAIT. |
@@ -220,7 +218,7 @@ This produces and signs the certificate but **does not submit it**. SUBMIT and W
220218
| :--: | ---- | ------------ |
221219
| CHECK | Verify prerequisites | Checks Anvil, L1 RPC, network type (PP only), threshold = 1, no custom gas token. |
222220
| 0 | Generate LBT | Resolves `targetBlock` to a concrete block number, then scans `NewWrappedToken` events and fetches `totalSupply` per wrapped token at that block. |
223-
| A | Collect addresses | Discovers every value-holding address from the final state (`debug_accountRange` state dump) plus `Transfer` event logs per wrapped token. Strategy selected by `addressDiscovery` (`auto` falls back to receipt harvesting when `debug_accountRange` is unavailable). |
221+
| 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. |
224222
| 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). |
225223
| 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. |
226224
| 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. |
@@ -317,18 +315,9 @@ Discovers every value-holding address at `targetBlock` from the **final state**
317315
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.
318316
2. **`Transfer` event logs** — scans `eth_getLogs` for each wrapped token (list from the Step 0 LBT) 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.
319317

320-
The strategy is selected by `options.addressDiscovery`:
318+
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.
321319

322-
| Value | Behaviour |
323-
| ----- | --------- |
324-
| `auto` (default) | Probe `debug_accountRange`; if supported, use state dump + Transfer logs. Otherwise fall back to **receipt harvesting** (block bodies + `eth_getTransactionReceipt` from `l2StartBlock`, in windows of `options.stepAWindowSize`) + Transfer logs, with a warning — the fallback misses internal value transfers (a CALL with value to a fresh address leaves no receipt entry). |
325-
| `stateDump` | State dump only. **Misses token-only EOAs**: an ERC-20 transfer only mutates the token contract's storage, so a recipient with no nonce/balance/code does not exist in the account trie. |
326-
| `logs` | Transfer logs only. **Misses native-ETH holders and contracts**: an account that never sent or received a wrapped token appears in no `Transfer` event, so it is never discovered and Step B never fetches its balances. (Addresses that *are* discovered do get both their token **and** ETH balances fetched.) |
327-
| `both` | State dump + Transfer logs; errors if `debug_accountRange` is unavailable. |
328-
329-
The two sources cover complementary blind spots, so **`stateDump` and `logs` alone are debugging modes** (e.g. to isolate one source or compare their outputs) — a certificate generated from a single source under-covers value and Step F will abort on the resulting LBT mismatch. Use `auto` or `both` for a real run.
330-
331-
The state dump fails loudly (instead of returning a truncated or empty set) when the node keeps returning a non-empty pagination cursor past the page cap or returns 0 accounts (e.g. a geth archive node without address preimages) — in `auto` mode the latter triggers the receipt-harvesting fallback.
320+
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.
332321

333322
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, so its balances must be scanned and covered by the certificate for the totals to reconcile with the LBT.
334323

tools/exit_certificate/cmd/main.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,8 @@ Pipeline steps (run in order by default):
2626
for wrapped token mappings.
2727
2828
A Collect every value-holding address at the target block via a state-trie
29-
dump (debug_accountRange) plus Transfer-event logs per wrapped token.
30-
Strategy is set via options.addressDiscovery (auto | stateDump | logs |
31-
both); "auto" falls back to receipt harvesting when debug_accountRange is
32-
unavailable.
29+
dump (debug_accountRange, for native-ETH holders and contracts) plus
30+
Transfer-event logs per wrapped token (for token holders).
3331
3432
B Scan EOA native-token balances and wrapped-token balances for every address
3533
found in step A.

tools/exit_certificate/config-examples/zkevm-cardona.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,12 @@ Password = "<PASSWORD>"
3838

3939
[options]
4040
blockRange = 10000
41-
stepAWindowSize = 10000
4241
concurrencyLimit = 10
4342
rpcBatchSize = 99
4443
rpcDelayMs = 10
4544
outputDir = "./output-cardona"
4645
l1StartBlock = 5157692
4746
l2StartBlock = 0
48-
addressDiscovery = "auto"
4947
ignoreGenesisBalance = false
5048
ignoreUnclaimed = false
5149
ignoreBalanceMismatch = false

tools/exit_certificate/config-examples/zkevm-mainnet.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,12 @@ Password = "<PASSWORD>"
3838

3939
[options]
4040
blockRange = 10000
41-
stepAWindowSize = 20000
4241
concurrencyLimit = 200
4342
rpcBatchSize = 99
4443
rpcDelayMs = 10
4544
outputDir = "./output-mainnet"
4645
l1StartBlock = 22431675
4746
l2StartBlock = 0
48-
addressDiscovery = "auto"
4947
ignoreGenesisBalance = false
5048
ignoreUnclaimed = false
5149
ignoreBalanceMismatch = false

tools/exit_certificate/config.go

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ import (
1919

2020
// Options holds tuning parameters for RPC parallelism and output.
2121
type Options struct {
22-
BlockRange int `json:"blockRange"`
23-
// StepAWindowSize is the number of blocks loaded into memory at once by Step A's
24-
// receipt-harvesting fallback (used when debug_accountRange is unavailable in "auto" mode).
25-
// Defaults to 150000, independently of BlockRange.
26-
StepAWindowSize int `json:"stepAWindowSize"`
22+
BlockRange int `json:"blockRange"`
2723
ConcurrencyLimit int `json:"concurrencyLimit"`
2824
RPCBatchSize int `json:"rpcBatchSize"`
2925
RPCDelayMs int `json:"rpcDelayMs"`
@@ -52,12 +48,6 @@ type Options struct {
5248
// genesis premint, clamping to 0 and silently dropping contract-held ETH. Set to false to fall
5349
// back to the LBT − EOA derivation for the native token.
5450
NativeSCLockedFromContracts bool `json:"nativeSCLockedFromContracts"`
55-
// AddressDiscovery selects the strategy used by Step A to collect value-holding addresses:
56-
// "stateDump" (debug_accountRange only), "logs" (Transfer logs only), "both", or "auto"
57-
// (the default: probe debug_accountRange and use both, else fall back to receipt harvesting
58-
// plus Transfer logs). The single-source modes are debugging aids — the dump misses token-only
59-
// EOAs and the logs miss native-ETH holders and contracts — so real runs need "auto" or "both".
60-
AddressDiscovery string `json:"addressDiscovery"`
6151
// IgnoreBalanceMismatch suppresses the error returned by Step F when token balances
6252
// do not match. Set to true only when investigating discrepancies without blocking the pipeline.
6353
IgnoreBalanceMismatch bool `json:"ignoreBalanceMismatch"`
@@ -153,21 +143,18 @@ type Config struct {
153143

154144
const (
155145
defaultBlockRange = 5000
156-
defaultStepAWindowSize = 150000
157146
defaultConcurrencyLimit = 20
158147
defaultRPCBatchSize = 200
159148
)
160149

161150
var defaultOptions = Options{
162151
BlockRange: defaultBlockRange,
163-
StepAWindowSize: defaultStepAWindowSize,
164152
ConcurrencyLimit: defaultConcurrencyLimit,
165153
RPCBatchSize: defaultRPCBatchSize,
166154
RPCDelayMs: 0,
167155
OutputDir: "output",
168156
L1StartBlock: 0,
169157
L2StartBlock: 0,
170-
AddressDiscovery: addressDiscoveryAuto,
171158
UseAgglayerAdminToStepFCheck: true,
172159
VerifyNewLocalExitRootUsingShadowFork: true,
173160
CapMode: CapModeNone,
@@ -410,9 +397,6 @@ func mergeScalarOptions(opts *Options, raw *rawOpts, configDir string) {
410397
if raw.BlockRange > 0 {
411398
opts.BlockRange = raw.BlockRange
412399
}
413-
if raw.StepAWindowSize > 0 {
414-
opts.StepAWindowSize = raw.StepAWindowSize
415-
}
416400
if raw.ConcurrencyLimit > 0 {
417401
opts.ConcurrencyLimit = raw.ConcurrencyLimit
418402
}
@@ -431,9 +415,6 @@ func mergeScalarOptions(opts *Options, raw *rawOpts, configDir string) {
431415
if raw.L2StartBlock > 0 {
432416
opts.L2StartBlock = raw.L2StartBlock
433417
}
434-
if raw.AddressDiscovery != "" {
435-
opts.AddressDiscovery = raw.AddressDiscovery
436-
}
437418
if raw.AgglayerAdminURL != "" {
438419
opts.AgglayerAdminURL = raw.AgglayerAdminURL
439420
}
@@ -531,14 +512,12 @@ type rawConfig struct {
531512

532513
type rawOpts struct {
533514
BlockRange int `json:"blockRange"`
534-
StepAWindowSize int `json:"stepAWindowSize"`
535515
ConcurrencyLimit int `json:"concurrencyLimit"`
536516
RPCBatchSize int `json:"rpcBatchSize"`
537517
RPCDelayMs int `json:"rpcDelayMs"`
538518
OutputDir string `json:"outputDir"`
539519
L1StartBlock uint64 `json:"l1StartBlock"`
540520
L2StartBlock uint64 `json:"l2StartBlock"`
541-
AddressDiscovery string `json:"addressDiscovery"`
542521
AgglayerAdminURL string `json:"agglayerAdminURL"`
543522
AgglayerAdminToken string `json:"agglayerAdminToken"`
544523
AgglayerClient *agglayer.ClientConfig `json:"agglayerClient"`

tools/exit_certificate/config_test.go

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -345,59 +345,13 @@ func TestLoadConfig_DefaultOptions(t *testing.T) {
345345
cfg, err := LoadConfig(path)
346346
require.NoError(t, err)
347347
require.Equal(t, 5000, cfg.Options.BlockRange)
348-
require.Equal(t, 150000, cfg.Options.StepAWindowSize)
349348
require.Equal(t, 20, cfg.Options.ConcurrencyLimit)
350349
require.Equal(t, 200, cfg.Options.RPCBatchSize)
351350
require.Equal(t, 0, cfg.Options.RPCDelayMs)
352351
require.Equal(t, uint64(0), cfg.Options.L1StartBlock)
353352
require.True(t, cfg.Options.UseAgglayerAdminToStepFCheck)
354353
}
355354

356-
func TestLoadConfig_StepAWindowSize(t *testing.T) {
357-
t.Parallel()
358-
359-
t.Run("explicit value is read from file", func(t *testing.T) {
360-
t.Parallel()
361-
362-
path := filepath.Join(t.TempDir(), "cfg.json")
363-
data := `{
364-
"l2RpcUrl": "http://localhost:8545",
365-
"l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe",
366-
"exitAddress": "0x0000000000000000000000000000000000000001",
367-
"targetBlock": "100",
368-
"options": {
369-
"agglayerAdminURL": "https://admin.example.com",
370-
"stepAWindowSize": 2000
371-
}
372-
}`
373-
require.NoError(t, os.WriteFile(path, []byte(data), 0o600))
374-
375-
cfg, err := LoadConfig(path)
376-
require.NoError(t, err)
377-
require.Equal(t, 2000, cfg.Options.StepAWindowSize)
378-
})
379-
380-
t.Run("defaults to 5000 when absent", func(t *testing.T) {
381-
t.Parallel()
382-
383-
path := filepath.Join(t.TempDir(), "cfg.json")
384-
data := `{
385-
"l2RpcUrl": "http://localhost:8545",
386-
"l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe",
387-
"exitAddress": "0x0000000000000000000000000000000000000001",
388-
"targetBlock": "100",
389-
"options": {
390-
"agglayerAdminURL": "https://admin.example.com"
391-
}
392-
}`
393-
require.NoError(t, os.WriteFile(path, []byte(data), 0o600))
394-
395-
cfg, err := LoadConfig(path)
396-
require.NoError(t, err)
397-
require.Equal(t, defaultStepAWindowSize, cfg.Options.StepAWindowSize)
398-
})
399-
}
400-
401355
func TestLoadConfig_RelativeOutputDir(t *testing.T) {
402356
t.Parallel()
403357

@@ -524,7 +478,6 @@ func TestMergeOptions_BoolFlags(t *testing.T) {
524478
"options": {
525479
"agglayerAdminURL": "https://admin.example.com",
526480
"ignoreGenesisBalance": true,
527-
"addressDiscovery": "stateDump",
528481
"ignoreBalanceMismatch": true,
529482
"ignoreUnclaimed": true
530483
}
@@ -534,7 +487,6 @@ func TestMergeOptions_BoolFlags(t *testing.T) {
534487
cfg, err := LoadConfig(path)
535488
require.NoError(t, err)
536489
require.True(t, cfg.Options.IgnoreGenesisBalance)
537-
require.Equal(t, addressDiscoveryStateDump, cfg.Options.AddressDiscovery)
538490
require.True(t, cfg.Options.IgnoreBalanceMismatch)
539491
require.True(t, cfg.Options.IgnoreUnclaimed)
540492
}

0 commit comments

Comments
 (0)