Skip to content

feat: new tool - exit_certificate#1582

Open
krlosMata wants to merge 59 commits into
developfrom
feature/exit-certificate-tool
Open

feat: new tool - exit_certificate#1582
krlosMata wants to merge 59 commits into
developfrom
feature/exit-certificate-tool

Conversation

@krlosMata

@krlosMata krlosMata commented Apr 14, 2026

Copy link
Copy Markdown
Member

🔄 Changes Summary

New exit-certificate CLI tool under tools/exit_certificate/ that generates exit certificates for aggchain migration. It scans L2 state from genesis to a target block, discovers all addresses with value (ETH + wrapped tokens), computes smart-contract-locked balances, detects unclaimed L1→L2 bridge deposits, verifies balances against the agglayer, computes the new LocalExitRoot via a shadow-fork, and produces a fully signed agglayer Certificate ready for submission.

Full pipeline: CHECK → 0 → A → B → C → D → E → F → G → H → I → SIGN → SUBMIT → WAIT

  • Step CHECK — prerequisite verification (Anvil installed, L1 RPC reachable, L2 network ID, PP network type, threshold=1, no gas token)
  • Step 0 — LBT generation: scans NewWrappedToken / SetSovereignTokenAddress events, fetches totalSupply per token
  • Step A — address collection via debug_traceTransaction with prestateTracer+diffMode
  • Step B — EOA classification + ETH/ERC-20 balance scanning
  • Step C — SC-locked value computation (LBT total − EOA accumulated)
  • Step D — build agglayer Certificate with BridgeExit entries
  • Step E — unclaimed L1→L2 deposit detection; optional cross-check against bridge service REST API
  • Step F — agglayer token balance verification via admin_getTokenBalance; proportional capping on mismatch
  • Step GNewLocalExitRoot computation via Anvil shadow-fork of the L2 chain
  • Step H — fetch PreviousLocalExitRoot from agglayer via gRPC
  • Step I — assemble final certificate (NewLER + PrevLER + L1InfoTreeLeafCount from L1)
  • Step SIGN — ECDSA certificate signing via go_signer
  • Step SUBMIT — send certificate to agglayer via gRPC
  • Step WAIT — poll agglayer until the certificate is Settled or InError

Steps can be run individually or as a full pipeline via --step flag. Intermediate results are persisted to an output directory, enabling step-by-step execution and resumability.

Additional changes in this PR:

  • Bearer token auth support for agglayer admin API protected by Google Cloud IAP (options.agglayerAdminToken)
  • Unit test coverage for hex, rpc, config, step_f, and step_g utilities
  • Lint fixes across the repo (lll, mnd, gci, whitespace, goconst, gocritic, makezero, unparam, prealloc, gosec)

⚠️ Breaking Changes

  • None. This is a new standalone tool with no impact on existing aggkit components.

📋 Config Updates

  • 🧾 New config file: tools/exit_certificate/parameters.json (standalone JSON). Key fields:
{
    "l2RpcUrl": "https://your-l2-rpc.example.com",
    "l1RpcUrl": "https://your-l1-rpc.example.com",
    "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe",
    "l2NetworkId": 1,
    "targetBlock": "latest",
    "exitAddress": "0x0000000000000000000000000000000000000001",
    "destinationNetwork": 0,
    "sovereignRollupAddr": "0x...",
    "l1GlobalExitRootAddress": "0x...",
    "signerConfig": { "Method": "local", "Path": "keystore.json", "Password": "..." },
    "options": {
        "blockRange": 5000,
        "concurrencyLimit": 20,
        "rpcBatchSize": 200,
        "outputDir": "./output",
        "agglayerAdminURL": "https://admin-agglayer.example.com",
        "agglayerAdminToken": "<IAP bearer token>",
        "agglayerClient": { "GRPC": { "URL": "agglayer.example.com:50051", "UseTLS": true } }
    }
}

✅ Testing

  • 🤖 Automatic: unit tests in tools/exit_certificate/ (hex, rpc, config, step_c, step_f, step_g)
  • 🖱️ Manual: tested against spec/bali environments with real L2 chains

🐞 Issues

📝 Notes

  • Requires anvil (Foundry) in $PATH for Step G
  • Requires archive node for Step A (debug_traceTransaction)
  • FEP chains are not supported — only Pessimistic Proof certificates

@claude

claude Bot commented Apr 14, 2026

Copy link
Copy Markdown

Claude finished @krlosMata's task in 4m 37s —— View job


PR Review: exit_certificate Tool ✅


🚀 What's New

A new standalone CLI tool (exit-certificate) that generates exit certificates for aggchain migration. The tool scans L2 state from genesis to a target block, discovers all value (ETH + wrapped tokens), computes smart-contract-locked balances, detects unclaimed L1→L2 bridge deposits, and produces a standard agglayer Certificate with BridgeExit entries that bridge all value back to L1. Includes a 6-step resumable pipeline with ~1,315 lines of tests (34% of total code).

🐛 Bug Fixes

None.

📋 Config Updates

New config file: tools/exit_certificate/parameters.json (standalone JSON, not part of aggkit's main config).

Required fields:

  • l2RpcUrl (string): L2 JSON-RPC endpoint (must support debug_traceTransaction)
  • l2BridgeAddress (hex address): L2 bridge contract address
  • targetBlock (string): Target block number or "latest"

Optional fields:

  • l1RpcUrl (string): L1 JSON-RPC endpoint (required for Step E)
  • l1BridgeAddress (hex address): L1 bridge contract (defaults to l2BridgeAddress)
  • l2NetworkId (uint32): L2 network ID (default: 1)
  • exitAddress (hex address): Address for SC-locked value exits (default: zero address)
  • destinationNetwork (uint32): Destination network (default: 0)
  • lbtFile (string): Path to pre-generated LBT JSON file (optional)
  • options.blockRange (int): Block range per eth_getLogs (default: 5000)
  • options.concurrencyLimit (int): Max concurrent RPC requests (default: 20)
  • options.rpcBatchSize (int): Max calls per batch (default: 200)
  • options.rpcDelayMs (int): Delay between batches (default: 0)
  • options.outputDir (string): Output directory (default: "./output")
  • options.l1StartBlock (uint64): L1 scan start block (default: 0)

⚠️ Breaking Changes

None. This is a new standalone tool with no impact on existing aggkit components.


Code Quality Assessment

✅ Strengths

  1. Excellent Architecture: Clean 6-step pipeline with clear separation of concerns. Each step has well-defined inputs/outputs and can run independently.

  2. Strong Test Coverage: ~1,315 lines of tests (34% of codebase) with good unit and integration coverage. Tests use proper table-driven patterns and edge case validation.

  3. Resumability: Intermediate results persisted to disk enable step-by-step execution and recovery from failures.

  4. Performance-Conscious: Custom HTTP client with unlimited connections, worker pools with proper concurrency control, and batched RPC calls.

  5. Error Handling: Comprehensive error wrapping with context throughout. Retry logic with exponential backoff for RPC calls.

  6. Good Documentation: Detailed README with clear usage examples, config documentation, and pipeline explanations.


🔴 Required Issues

1. Security: Unchecked Big Number Conversions

Location: step_0.go:163, step_e.go:229-236, hex.go:10-25

The code uses unchecked type conversions from *big.Int to uint32/uint64 which can silently truncate values:

// step_0.go:163
OriginNetwork: uint32(new(big.Int).SetBytes(data[0:32]).Uint64())

// step_e.go:229
LeafType: uint8(new(big.Int).SetBytes(data[0:32]).Uint64())

// hex.go - no bounds checking
func hexToUint64(s string) uint64 {
    var n uint64
    for _, c := range s {
        n <<= 4  // Can overflow silently
        // ...
    }
}

Impact: If a malicious or corrupted event contains a value larger than uint32_MAX or uint64_MAX, it will be silently truncated, potentially causing incorrect certificate generation or missing funds.

Recommendation: Add overflow checks before conversions:

val := new(big.Int).SetBytes(data[0:32])
if val.Uint64() > math.MaxUint32 {
    return wrappedTokenEvent{}, fmt.Errorf("originNetwork value too large: %s", val)
}
originNetwork := uint32(val.Uint64())

Fix this →

2. Security: Unvalidated Array Bounds in Event Decoding

Location: step_0.go:156-166, step_e.go:209-239

Event decoding functions check minimum data length but don't validate upper bounds when accessing dynamic metadata:

// step_e.go:218-225
metadataOffset := new(big.Int).SetBytes(data[192:224]).Uint64()
var metadata []byte
if metadataOffset+32 <= uint64(len(data)) {  // Check offset+32
    metadataLen := new(big.Int).SetBytes(data[metadataOffset : metadataOffset+32]).Uint64()
    metadataStart := metadataOffset + 32
    if metadataStart+metadataLen <= uint64(len(data)) {  // Check length
        metadata = make([]byte, metadataLen)
        copy(metadata, data[metadataStart:metadataStart+metadataLen])  // But no max size limit!
    }
}

Impact: A malicious event with an extremely large metadataLen could cause excessive memory allocation (DoS) or integer overflow in the offset calculation.

Recommendation: Add maximum size limits:

const maxMetadataSize = 1024 * 1024 // 1MB
if metadataLen > maxMetadataSize {
    return L1Deposit{}, fmt.Errorf("metadata too large: %d bytes", metadataLen)
}

Fix this →

3. Bug: Potential Integer Overflow in Batch Size Calculation

Location: step_0.go:189

batchSize := max(len(calls)/concurrency, 1)

If len(calls) is very large and concurrency is 1, this could create an extremely large batch that exceeds RPC limits.

Recommendation: Cap batch size to the configured RPCBatchSize:

batchSize := min(max(len(calls)/concurrency, 1), cfg.Options.RPCBatchSize)

Fix this →

4. Bug: Step C Doesn't Handle Native Token (Zero Address) Correctly

Location: step_c.go:83-88

The native token has WrappedTokenAddress == common.Address{} (zero address), but the indexing function uses strings.ToLower(e.WrappedTokenAddress.Hex()) which converts it to "0x0000000000000000000000000000000000000000". This is fine, but Step C doesn't explicitly handle the native token case:

func indexByAddress(entries []LBTEntry) map[string]LBTEntry {
    m := make(map[string]LBTEntry, len(entries))
    for _, e := range entries {
        m[strings.ToLower(e.WrappedTokenAddress.Hex())] = e  // Zero address becomes "0x000...000"
    }
    return m
}

In step_d.go:54-56, there's special handling for native tokens, but Step C doesn't distinguish between native and wrapped tokens when computing SC-locked values.

Recommendation: Add explicit handling for the native token entry or add a comment explaining that native tokens are intentionally treated the same way.

5. Bug: Missing Validation for TargetBlock Format

Location: run.go:69-79

The parseBlockNumber function silently returns 0 for invalid inputs:

func parseBlockNumber(s string) uint64 {
    // ... parse logic ...
    var n uint64
    if _, err := fmt.Sscanf(s, "%d", &n); err == nil {
        return n
    }
    return 0  // Returns 0 on error!
}

Impact: If a user provides an invalid targetBlock value (e.g., "abc"), it silently becomes block 0 instead of returning an error.

Recommendation: Return an error from this function and propagate it.

Fix this →


🟡 Suggested Improvements

6. Performance: RPC Batch Size Not Respected in Step A

Location: step_a.go:61, step_a.go:96

Step A uses concurrentBatchRPC with a hardcoded batch size calculation, ignoring cfg.Options.RPCBatchSize:

headerResults, err := concurrentBatchRPC(ctx, rpcURL, headerCalls, batchSize, concurrency)

But batchSize is set to cfg.Options.RPCBatchSize in the caller. However, in Step 0, the batch size is calculated dynamically. This inconsistency could lead to performance issues.

Recommendation: Consistently use cfg.Options.RPCBatchSize across all steps or document why some steps calculate it differently.

7. Code Quality: Inconsistent Error Handling in Worker Pools

Location: worker.go:66-83

The worker pool returns only the first error encountered but continues processing:

if r.err != nil {
    if firstErr == nil {
        firstErr = r.err
    }
    log.Warnf("%s job failed: %v", label, r.err)
    continue  // Keeps going even after first error
}

Behavior Concern: If many jobs fail, the tool continues processing and only returns the first error at the end. This could waste time on a fundamentally broken operation.

Recommendation: Consider adding a failFast parameter or error threshold to stop early on repeated failures.

8. Code Quality: Magic Numbers in hex.go

Location: hex.go:14-24, step_e.go:114

Several magic numbers lack clarity:

leafIndexMask := new(big.Int).SetUint64(0xFFFFFFFF) //nolint:mnd
mainnetFlag := new(big.Int).Lsh(big.NewInt(1), 64) //nolint:mnd

While the //nolint:mnd comment suppresses the linter, it would be clearer to define these as package-level constants with descriptive names:

const (
    globalIndexLeafMask    = 0xFFFFFFFF
    globalIndexMainnetBit  = 64
)

Fix this →

9. Observability: Missing Progress Logging in Step D

Location: step_d.go:17-75

Step D processes EOA balances and SC-locked values but doesn't log progress during iteration (unlike other steps). For large migrations with thousands of exits, this could leave users wondering if the process is hung.

Recommendation: Add progress logging similar to other steps.

10. Code Quality: Inconsistent Nil Check Pattern

Location: step_b.go:220-228

func unmarshalHexBigInt(result json.RawMessage) *big.Int {
    if result == nil {
        return nil
    }
    var hex string
    if json.Unmarshal(result, &hex) != nil || hex == "" || hex == "0x" {
        return nil
    }
    return hexToBigInt(hex)
}

This function returns nil for invalid inputs, which is then checked in step_0.go:198-200:

supply := unmarshalHexBigInt(result)
if supply == nil {
    supply = new(big.Int)  // Convert nil to zero
}

Pattern Concern: Treating nil as "zero" is semantically different from "error". A missing RPC result vs. a zero balance should be distinguished.

Recommendation: Consider returning (*big.Int, error) to distinguish between zero values and errors.


🟢 Optional Enhancements

11. Go Best Practices: Use crypto/rand for Worker Pool Channel Cap

Location: worker.go:9

const workerPoolChannelCap = 10000

This is a reasonable constant, but the value seems arbitrary. Consider documenting why 10,000 was chosen or making it configurable.

12. Testing: Missing Tests for Edge Cases

While test coverage is good overall, some edge cases lack explicit tests:

  • Step A with chain that has never had a transaction
  • Step E with L2ClaimEvents that have non-mainnet globalIndex flags
  • Boundary condition where metadataOffset is exactly at the end of data

13. Documentation: Add Sequence Diagram

The README is excellent, but a sequence diagram showing the data flow through all 6 steps would help users understand dependencies.

14. Code Style: Inconsistent Use of Named Return Values

Most functions don't use named return values, but classifyAddresses (step_b.go:75) does:

func classifyAddresses(...) (eoas, contracts []common.Address, err error)

For consistency with the rest of the codebase, consider using unnamed returns.

15. Performance: Consider Adding Rate Limiting

The tool has rpcDelayMs but it's applied between batches, not per request. For RPC providers with strict rate limits, adding a proper rate limiter (e.g., golang.org/x/time/rate) would be more robust.


📊 CLAUDE.md Compliance

Formatting: Uses gofmt, goimports
Imports: Properly grouped (stdlib, external, internal)
Naming: CamelCase for exports, no stuttering
Errors: Uses fmt.Errorf with %w for wrapping
Line length: All lines under 120 chars
Comments: Exported functions have doc comments
Tests: Uses testify/require (not assert) ✅
File naming: All test files use _test.go suffix

Minor deviation: Uses require consistently (good), but CLAUDE.md says "Use require (not assert)" which is satisfied.


🏗️ Architecture Assessment

The tool demonstrates excellent software engineering:

  1. Modularity: Each step is independently testable and resumable
  2. Concurrency: Thoughtful use of worker pools, semaphores, and batching
  3. Reliability: Retry logic, error handling, and graceful degradation
  4. Usability: Clear CLI, good defaults, comprehensive README
  5. Maintainability: Clean code structure, good test coverage

The main areas for improvement are security validations around untrusted input (event decoding) and more consistent error handling patterns.


🎯 Summary

This is a high-quality implementation of a complex migration tool. The code is well-structured, thoroughly tested, and production-ready with some security hardening.

Priority fixes:

  1. 🔴 Add overflow checks for big.Int conversions (Unify L1 syncers #1)
  2. 🔴 Add bounds validation for event metadata (Split L1 / L2 responsibilities for lastgersync #2)
  3. 🔴 Cap batch size to respect RPCBatchSize (Add UTs for lastgersync #3)
  4. 🔴 Add validation for targetBlock format (Add monitoring #5)

Recommended improvements:

Test status: CI checks are currently in progress. I'll monitor for any failures.


@krlosMata krlosMata force-pushed the feature/exit-certificate-tool branch from 6d18df4 to 85a1fa9 Compare April 14, 2026 08:58
@krlosMata krlosMata changed the title new tool: exit_certificate feat: new tool - exit_certificate Apr 14, 2026
@joanestebanr joanestebanr requested a review from Copilot April 15, 2026 09:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new standalone exit-certificate CLI tool under tools/exit_certificate/ to generate Agglayer exit certificates for aggchain migration by scanning L2/L1 state and producing an agglayer/types.Certificate with BridgeExit entries.

Changes:

  • Implements a 6-step pipeline (0 → A → B → C → D → E) for LBT generation, address discovery, balance scanning, SC-locked computation, certificate building, and unclaimed L1→L2 deposit detection.
  • Adds JSON-RPC batching/concurrency utilities plus step-wise resumable output persisted to an output directory.
  • Adds unit/integration tests and documentation/config examples for running the tool.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tools/exit_certificate/worker.go Generic worker-pool helper used to parallelize step workloads.
tools/exit_certificate/types.go Shared data structures for step outputs and deposit/event models.
tools/exit_certificate/step_0.go Step 0 implementation: generate LBT by scanning bridge logs + supplies.
tools/exit_certificate/step_a.go Step A implementation: scan blocks/txs and trace touched addresses.
tools/exit_certificate/step_b.go Step B implementation: EOA/contract classification and balance scanning.
tools/exit_certificate/step_c.go Step C implementation: compute SC-locked values from LBT vs EOA totals.
tools/exit_certificate/step_d.go Step D implementation: build the exit certificate BridgeExits.
tools/exit_certificate/step_e.go Step E implementation: scan L1 BridgeEvents and add unclaimed deposits.
tools/exit_certificate/rpc.go JSON-RPC client utilities: batch/single RPC, retries, concurrency batching.
tools/exit_certificate/hex.go Hex/decimal parsing helpers and ABI safety conversions.
tools/exit_certificate/config.go Config loading/validation, defaults, and LBT file parsing helpers.
tools/exit_certificate/run.go CLI execution wiring: full pipeline + step-by-step resumability and I/O.
tools/exit_certificate/cmd/main.go CLI binary entrypoint using urfave/cli.
tools/exit_certificate/README.md Tool documentation: config, steps, usage, outputs, testing.
tools/exit_certificate/.gitignore Ignore local parameters/output/binary artifacts for this tool.
tools/exit_certificate/parameters.json.example Example standalone JSON config for running the tool.
tools/exit_certificate/step_a_test.go Unit tests for hex block parsing helper(s) used in Step A.
tools/exit_certificate/step_b_test.go Unit tests for hex-to-bigint helper used in Step B.
tools/exit_certificate/step_c_test.go Unit tests for SC-locked computation behavior and edge cases.
tools/exit_certificate/step_d_test.go Unit tests for certificate construction from EOA + SC-locked inputs.
tools/exit_certificate/step_e_test.go Unit tests for BridgeEvent decoding and claimed-set filtering logic.
tools/exit_certificate/rpc_test.go Unit tests for batch/single RPC, retry behavior, and error handling.
tools/exit_certificate/run_test.go Unit tests for block parsing and JSON save/load helpers.
tools/exit_certificate/config_test.go Unit tests for config parsing, defaults, and LBT parsing helpers.
tools/exit_certificate/integration_test.go Integration-style tests for production-like config/data shapes (skippable).

Comment thread tools/exit_certificate/step_d.go Outdated
Comment thread tools/exit_certificate/run.go Outdated
Comment thread tools/exit_certificate/step_b.go Outdated
Comment thread tools/exit_certificate/rpc.go Outdated
Comment thread tools/exit_certificate/step_e.go Outdated
Comment thread tools/exit_certificate/step_a.go Outdated
Comment thread tools/exit_certificate/run.go Outdated
Comment thread tools/exit_certificate/step_e.go Outdated
@krlosMata krlosMata force-pushed the feature/exit-certificate-tool branch from 4936ffa to 63a43be Compare April 17, 2026 09:47
@sonarqubecloud

Copy link
Copy Markdown

@joanestebanr joanestebanr force-pushed the feature/exit-certificate-tool branch from ac0936d to 2122813 Compare May 4, 2026 16:12
@joanestebanr joanestebanr self-assigned this May 11, 2026
@sonarqubecloud

Copy link
Copy Markdown

@joanestebanr joanestebanr force-pushed the feature/exit-certificate-tool branch from 7eb9e06 to 427ae79 Compare June 12, 2026 13:19
@joanestebanr joanestebanr added the exit_certificate_tool Tool to create a final exit certificate label Jun 12, 2026
@sonarqubecloud

Copy link
Copy Markdown

krlosMata and others added 9 commits June 30, 2026 16:09
- Add overflow checks for big.Int to uint32/uint64 conversions (safeUint32, safeUint8)
- Add max metadata size validation (1MB) in decodeBridgeEvent to prevent DoS
- Cap batch size to RPCBatchSize in fetchTotalSupplies
- Return error from parseBlockNumber on invalid input instead of silent zero
- Extract globalIndex magic numbers to named constants
- Add progress logging to Step D
- Document native token handling in step_c indexByAddress
- Fix all golangci-lint issues (errcheck, gci, gosec, lll, mnd, prealloc, unparam)

Made-with: Cursor
- Scan L2 bridge for ClaimEvent logs so Step E correctly identifies
  already-claimed deposits instead of treating all as unclaimed (joanestebanr)
- Fail on trace/scan errors instead of warn+continue: traceTransactions,
  fetchL1BridgeEvents now propagate errors (partial scans are unsafe)
- Fix encodeBalanceOf: use zero-padding (LeftPadBytes) instead of
  space-padding (%064s) which produced invalid hex calldata
- Use strconv.ParseUint instead of fmt.Sscanf to reject trailing
  non-numeric input like "123abc"
- Set MaxIdleConnsPerHost=100 instead of 0 (0 defaults to 2 in net/http)
- Preserve OriginNetwork/OriginTokenAddress from LBT for native token
  entries (supports chains with custom gas tokens)
- Add decodeClaimEvent tests

Made-with: Cursor
- Extract magic number 32 to named constant abiWordSize in step_b.go
- Pre-allocate claims slice in fetchClaimEventsInRange

Made-with: Cursor
- Replace ClaimEvent log scanning with isClaimed(depositCount, 0)
  eth_call on L2 bridge contract (authoritative claimed bitmap)
- Extract helper functions across all steps to bring every function
  under diffguard thresholds (complexity ≤ 10, size ≤ 50 lines)

Made-with: Cursor
Add exit_certificate binary to the Makefile build-tools target so it
builds alongside the other tools. Add maskRPCURL helper that strips the
path from RPC URLs before logging, preventing API key exposure in error
messages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ndling and logging

- Add L2StartBlock option to config so block scanning starts from a
  configurable block instead of always from block 0
- Add label parameter to concurrentBatchRPC to identify each call site
  in progress logs (e.g. "L2 RPC/blockHeaders", "L2 RPC/balanceOf")
- Improve batchRPC: log individual RPC-level errors via log.Warn and
  return the first error instead of silently dropping failed responses;
  add response-count validation
- Add detailed app.Description to the CLI listing all pipeline steps
  (0, A, B, C, D, E) and how to run individual steps
- Add .PHONY declarations for build-tools targets in Makefile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
joanestebanr and others added 23 commits June 30, 2026 16:10
Explain why l1RpcUrl matters in practice, warn that exitAddress must
be a key you control, document the signerConfig format, and add a table
describing when to use continueOnTraceError, abortOnGenesisBalance and
ignoreUnclaimed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
It is needed by Step E and Step I; without it the certificate is incomplete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…EADME

List l1RpcUrl, exitAddress and signerConfig as the fields that must be
filled in before running the tool, and link to the main README for the
full field reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… API

Introduce options.agglayerAdminToken to pass an Authorization: Bearer
header when calling admin_getTokenBalance in Step F. Required when
agglayerAdminURL is protected by Google Cloud IAP.

Also replaces the flat agglayerGrpcUrl string option with a structured
agglayerClient config object (agglayer.ClientConfig), enabling TLS,
timeout and retry customization for gRPC steps H, SUBMIT, and WAIT.

Add ready-to-use config examples for zkevm-cardona and zkevm-mainnet
in config-examples/.

Documentation updated with IAP token instructions and environment-specific
service account / audience values for spec, bali, cardona, and mainnet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- tools/exit_certificate: fix lll, mnd, gci, whitespace, goconst,
  gocritic, makezero, unparam, prealloc, and errorlint issues; add
  named constants (abiWordBytes, ethDecimals, hexBase, etc.) to hex.go;
  remove unused params from fetchGasTokenInfo and checkNativeGasToken;
  expand unit test coverage with hex_test.go, step_g_test.go and
  additional cases in rpc_test.go, config_test.go, step_f_test.go
- aggsender, bridgeservice, multidownloader, scripts, backward_forward_let:
  suppress gosec false-positives with nolint directives
- db/migrations/testutils: add gosec nolint alongside existing mnd nolint
- l1infotreesync/migrations: preallocate migrations slice
- sync/evmdownloader_test: preallocate testCases slice

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds G204, G602, G703, G118 to the global gosec excludes in .golangci.yml
so local and CI golangci-lint produce identical results regardless of version.
Removes now-redundant //nolint:gosec directives from 6 files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
G703 and G118 are not valid rule IDs in gosec as bundled with golangci-lint
v2.4.0 (used by CI). Move them from gosec.excludes (schema-validated) to
exclusions.rules with text matching, which is version-agnostic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… for SC-locked exits (#1622)

## 🔄 Changes Summary

### Fix F-01: `ensureERC20Balance` storage patching for SC-locked ERC-20
exits
- Implements `ensureERC20Balance` in Step G, which was a stub that
always returned an error without actually patching Anvil storage.
- The function now patches `_balances[account]` via
`hardhat_setStorageAt` using a two-layout detection strategy, verifying
`balanceOf` after each attempt:
  1. **OZ v4 non-upgradeable**: `_balances` mapping at storage slot 0
2. **OZ v5 upgradeable**: `_balances` inside the namespaced
`ERC20Storage` struct at `ERC20StorageLocation =
0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00`
- Adds a package-level `erc20NamespacedStorageLocation` constant
documenting the OZ v5 storage namespace derivation.

### Refactor: remove `lbtFile` config option
- `lbtFile` was an escape hatch to skip Step 0 by providing a
pre-generated LBT. Step 0 now always runs, so the field is removed.
- Merges `RunStepCWithEntries` back into `RunStepC` (simpler API, no
config dependency).
- Updates `resolveOrGenerateLBT`, `loadWrappedTokensFromLBT`,
`runSingleC`, `runSingleB`, `runSingleF`, `runSingleG` accordingly.

### Kurtosis script improvements
- Adds two helper scripts for the Kurtosis test environment.
- Embeds a pre-generated exit address keypair
(`0xe25f5B65E4976025f670e52b790a9746F27A3DB6`) in
`configuration_based_on_kurtosis.sh` so the exit address is stable
across runs without requiring Foundry at script runtime. The private key
and an encrypted keystore (password: `test`) are written to `tmp/` on
first execution.

## ⚠️ Breaking Changes
- `lbtFile` config field removed. Any existing config files using it
will have the field silently ignored (unknown JSON fields are not
errors, but Step 0 will now always run).

## 📋 Config Updates
- `lbtFile` removed from `Config` and `rawConfig`. Step 0 is no longer
skippable via config.

## ✅ Testing
- 🤖 **Automatic**: Existing unit tests pass (`ok
github.com/agglayer/aggkit/tools/exit_certificate`)
- 🖱️ **Manual**:
1. Run `tools/exit_certificate/scripts/reproduce_sc_locked.sh` against a
live Kurtosis `aggkit` enclave
2. The script deploys a `TokenHolder` smart contract on L2, transfers
wrapped ERC-20 tokens to it, then drives the exit-certificate tool from
steps 0→G
3. Before fix: Step G failed with `ERC20InsufficientBalance`
(`0xe450d38c`) when replaying the SC-locked `BridgeExit`
  4. After fix: Step G completes and emits a valid `NewLocalExitRoot`

## 🐞 Issues
- Fixes: #1624 

## 🔗 Related PRs
- N/A

## 📝 Notes
- `TokenWrapped` (wTTK) deployed by `AgglayerBridge` uses OZ v5
upgradeable storage, so the slot-0 attempt is a no-op. The second
candidate (namespaced storage) is the one that matches and patches the
balance correctly.
- The two-layout approach is safe: a failed slot write leaves the token
balance unchanged and the loop moves to the next candidate. If neither
layout works the function returns a descriptive error.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…runs (#1627)

## 🔄 Changes Summary

- Changed `cfg.TargetBlock` type from `string` to
`aggkittypes.BlockNumberFinality`, enabling finality keywords
(`LatestBlock`, `FinalizedBlock`, `SafeBlock`, `PendingBlock`),
decimal/hex block numbers, and offset notation (e.g. `LatestBlock/-10`).
- Added `Step0Result.TargetBlock uint64` — the concrete resolved block
number is now part of the Step 0 output and persisted to
`step-0-l2_target_block.json` (new file).
- Removed `ResolvedTargetBlock` from `Config`; the resolved block number
is passed as an explicit `targetBlock uint64` parameter to `RunStepA`,
`RunStepB`, and `RunStepG`.
- Single-step runners (`runSingleA/B/G`) load the block from
`step-0-l2_target_block.json` via a new `loadTargetBlock` helper.
- Updated README with target-block resolution table and updated Step 0
output list.
- Added output-directory cleanup hint at the end of the kurtosis
configuration script.

## ⚠️ Breaking Changes

- 🛠️ **Config — `targetBlock` values renamed**: existing
`parameters.json` files must update their `targetBlock` field to use the
new PascalCase keywords:
  - `"latest"` → `"LatestBlock"`
  - `"finalized"` → `"FinalizedBlock"`
  - `"safe"` → `"SafeBlock"`
  - `"pending"` → `"PendingBlock"`

The default (empty string) still resolves to `LatestBlock`. Decimal and
hex block numbers are unchanged.

- 🗑️ **New output file**: `step-0-l2_target_block.json` is a new file
produced by Step 0. Note: `step-0-result.json` (from a previous PR) was
already renamed to `step-0-lbt.json`; any tooling still reading
`step-0-result.json` must be updated to `step-0-lbt.json`.

## 📋 Config Updates

```json
// Before
"targetBlock": "latest"

// After
"targetBlock": "LatestBlock"       // or "FinalizedBlock", "SafeBlock", "PendingBlock"
"targetBlock": "LatestBlock/-10"   // latest minus 10 blocks
"targetBlock": "12345678"          // decimal (unchanged)
"targetBlock": "0xBCDE34"          // hex (unchanged)
```

## ✅ Testing

- 🖱️ **Manual**: Run full pipeline with `targetBlock: "LatestBlock"` and
verify `step-0-l2_target_block.json` is written with a valid block
number; subsequent single-step runs for A, B, and G should pick it up
automatically.

## 🐞 Issues

- Closes #1625

## 🔗 Related PRs

- Base branch: `feat/exit_certificate_f01_token_sclocked`

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## 🔄 Changes Summary
- Propagate trace errors through the worker pool and abort all in-flight
workers on first failure when `ContinueOnTraceError=false` (F-09)
- Add `StepAWindowSize` config option to tune Step A chunk size
independently from `blockRange`
- Replace `[]common.Hash failedTraces` with `[]FailedTrace{Hash, Error}`
so callers get the RPC error alongside the hash
- Promote `fetchSetSovereignTokenEvents` /
`applySovereignTokenOverrides` errors from silent `log.Warn` to returned
errors in `RunStep0`
- Fix variable shadowing bug for `nativeEntry` in `RunStep0` (`:=` →
`=`)
- Remove dead `lbtFile` config field and merge `RunStepCWithEntries`
back into `RunStepC`

## ⚠️ Breaking Changes
- 🛠️ **Config**: `lbtFile` option removed from `Options` — it was an
escape hatch to skip Step 0 that is no longer needed since Step 0 always
runs
- 🛠️ **Config**: `blockRange` no longer controls the block window size
in Step A — `stepAWindowSize` is now used exclusively for that (defaults
to 5000). Existing configs that relied on `blockRange` to tune Step A
throughput should add an explicit `stepAWindowSize`
- 🔌 **API/CLI**: `runWorkerPool`, `startWorkers`, `collectResults` now
require a `context.Context` as first argument

## 📋 Config Updates
- 🧾 New optional field `stepAWindowSize` (default: 5000):
```json
"options": {
    "blockRange": 10000,
    "stepAWindowSize": 10000
}
```

## ✅ Testing
- 🤖 **Automatic**:
- Unit tests for `traceOneTransaction` (success, dedup, RPC error, bad
JSON, null+error)
- Unit tests for `traceTransactions` (continueOnError path,
abort-on-error path)
- End-to-end `TestRunStepA_AbortOnTraceError` against a fake HTTP server
verifying context cancellation stops the pool
- 🖱️ **Manual**: Run Step A with a node that returns trace errors with
`continueOnTraceError=false` and verify the tool exits immediately with
the offending hash and error message

## 🐞 Issues
- Closes agglayer/pm#346
- Partially fixes agglayer/pm#349 (`Problem 2 — Variable shadowing makes
nil-check dead code`)

## 🔗 Related PRs
- Base: feat/exit_certificate_f05_target_block (block finality
resolution for Step 0)

## 📝 Notes
- `StepAWindowSize` exists because `debug_traceTransaction` RPC calls
are more expensive than `eth_getLogs`; operators may want a smaller
window for Step A without changing the log-query range used by Steps 0,
B, and E
- Context cancellation in `collectResults` drains `resultCh` in a
background goroutine to let workers release resources cleanly instead of
blocking

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
… rollup diagnostics (#1630)

## 🔄 Changes Summary

- **Step A → A1 + A2 split**: Step A is now two sub-steps. A1 runs
`debug_traceTransaction` (prestateTracer + diffMode) as before and
records failed hashes. A2 recovers addresses from
`eth_getTransactionReceipt` for each A1 failure, extracting
from/to/contractAddress/log emitters. The combined
`step-a-addresses.json` is the union of both.
- **Step aliases**: `--step a` expands to `a1,a2`; both sub-steps are
individually addressable (`--step a1`, `--step a2`). Range syntax works
too (`a-b` → `a1,a2,b`).
- **Migration**: on startup, legacy `step-a-*` files are renamed to
`step-a1-*` so existing output dirs remain usable without re-running A1.
- **Legacy rollup diagnostics**: when `AGGCHAINTYPE()` fails
(pre-aggchainbase contract), `logLegacyRollupInfo` queries the rollup
manager to surface `rollupID`, `rollupTypeID`, `chainID`, `forkID`,
`rollupVerifierType`, and full `rollupTypeMap` info (consensusImpl,
verifier, obsolete, genesis, programVKey) as diagnostic log lines.

## ⚠️ Breaking Changes

- 🔌 **CLI**: `--step a` still works as before (runs both sub-steps).
`step-a1-addresses.json` and `step-a1-failed-traces.json` are the new
canonical A1 outputs; `step-a-failed-traces.json` is no longer written
directly (migrated from legacy on startup).

## ✅ Testing

- 🤖 **Automatic**: `TestParseStepList` extended with `a`, `a-b`, `a2-b`
cases.
- 🖱️ **Manual**: run `--step a` on a chain with trace failures to verify
A2 recovers addresses from receipts.

## 📝 Notes

- A2 never aborts on receipt failures — it logs a warning and skips, so
the pipeline always produces a result even when receipts are also
unavailable.
- The legacy rollup diagnostics path does not modify check failures or
results; it is purely informational output to help diagnose
pre-aggchainbase deployments.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…act holder decomposition (#1632)

## 🔄 Changes Summary
- **Step B2**: probes each contract address collected in Step A for the
ERC-20 interface (`totalSupply`/`balanceOf`). Classifies contracts as
`DetectedERC20` (holds ≥1 tracked wrapped token) or `DiscardedERC20`.
Outputs `step-b2-detected-erc20s.json` and
`step-b2-discarded-erc20s.json`.
- **Step B3**: iterates over `options.extraErc20Contracts`. Reuses B2
holder data when available; otherwise calls `balanceOf` for every EOA
from Step A. Outputs `step-b3-erc20-holders.json`.
- **Step C**: extended to incorporate SC-locked values from B2 detected
ERC-20s.
- **Step D**: generates `BridgeExit` entries for ERC-20-locked balances
from B2/B3.
- **config**: added `extraErc20Contracts` option; increased
`defaultStepAWindowSize` from 5 000 to 150 000.
- Pipeline (`run.go`) updated to execute B2 and B3 between B and C.
- New types, RPC helpers, unit tests, docs and example config for the
new steps.

## ⚠️ Breaking Changes
- 🛠️ **Config**: new optional field `options.extraErc20Contracts` (array
of addresses). No breaking change — defaults to empty.

## 📋 Config Updates
```json
"options": {
  "stepAWindowSize": 150000,
  "extraErc20Contracts": ["0xTokenAddress1", "0xTokenAddress2"]
}
```

## ✅ Testing
- 🤖 **Automatic**: unit tests added for Step B2 (`step_b2_test.go`) and
Step B3 (`step_b3_test.go`); Step C tests extended.
- 🖱️ **Manual**: run full pipeline with `extraErc20Contracts` populated
and verify `step-b2-detected-erc20s.json`, `step-b3-erc20-holders.json`
outputs.

## 🐞 Issues
- Closes agglayer/pm#341

## 📝 Notes
- Step B3 short-circuits when `extraErc20Contracts` is empty — no RPC
calls made.
- `defaultStepAWindowSize` raised to 150 000 to reduce RPC round-trips
on chains with large block ranges.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…shadow-fork verification (#1633)

## 🔄 Changes Summary

Speeds up and reworks **Step G** (NewLocalExitRoot), splitting it into
**G1** and **G2** and adding an off-chain computation path.

**Step G split**
- **G1** (`step_g1.go`): lite-syncs the L2 bridge history from genesis
up to the target block into a persistent lite DB, using the new
`bridgesyncerlite` package (reads `BridgeEvent` logs in parallel and
builds a bridge exit tree byte-for-byte compatible with `bridgesync`).
Resolves the shadow-fork block.
- **G2** (`step_g2.go`, formerly `step_g.go`): computes
`NewLocalExitRoot`.
- **Default** (`verifyNewLocalExitRootUsingShadowFork=true`): spins up
the Anvil shadow-fork, replays every bridge exit in parallel
(send/collect pipeline), reorders the certificate to the on-chain
deposit order, and **verifies** the lite exit tree root against the
contract's `getRoot()`.
- **Off-chain** (`=false`): computes the root purely from the lite tree
(G1 bridges + the certificate's exits, in order) — no Anvil.

**Step I always uses the reordered certificate**
- In single-step mode, Step I now **always** reads
`step-g-reordered-certificate.json` (run Step G first) instead of
falling back to the capped/Step-E certificates, so the final certificate
always matches the computed `NewLocalExitRoot`. (`runAll` already flowed
the in-memory reordered cert.)

**Removed**
- `options.depositOrderSource` (the `events`/`bridgesync` modes) and the
production-bridgesync recovery (`step_g_bridgesync.go`). Deposit order
now comes from the replay's `BridgeEvent`s (shadow-fork) or the
certificate order (off-chain). `StepGResult.ShadowForkFirstBlock`
dropped.

**New `bridgesyncerlite` package**
- Minimal bridge syncer: parallel `eth_getLogs`, persists `BridgeEvent`
leaves and builds the exit tree. Supports a **DB-only** mode (no RPC) so
G2 can insert pre-collected leaves and build the tree without touching
Anvil. Aborts on events that invalidate a `BridgeEvent`-only
reconstruction (`SetSovereignTokenAddress`, `MigrateLegacyToken`,
`RemoveLegacySovereignTokenAddress`, `BackwardLET`, `ForwardLET`) unless
`ignoreUnsupportedL2Events` is set.

> ⚠️ On mainnet Step G replays ~915k bridge exits; the previous serial
execution took ~4 days (~2.8 bridges/s). The parallel replay + off-chain
option address this.

## ⚠️ Breaking Changes
- 🛠️ **Config**: `exitAddress` is now **mandatory** — `LoadConfig`
errors when it is missing or set to the zero address (`0x00…00`).
Configs that previously omitted it (it defaulted to the zero address)
now fail. SC-locked value is bridged to this address and can only be
recovered by signing from an address whose private key the operator
controls.
- 🛠️ **Config**: option renames (to the `ignore*` convention) —
`abortOnGenesisBalance` → `ignoreGenesisBalance` *(polarity inverted:
default `false` = abort)*, `continueOnTraceError` →
`ignoreOnTraceError`, `continueIfBalanceMismatch` →
`ignoreBalanceMismatch`.
- 🗑️ **Deprecated Features**: removed `options.depositOrderSource`;
removed the `config-examples/` `.json` variants (converted to `.toml`).

## 📋 Config Updates

**Config accepts JSON _or_ TOML**
- `LoadConfig` selects the format by file extension: `.toml` is parsed
as TOML, anything else (`.json`/no extension) as JSON. TOML is
normalized to JSON internally (`tomlToJSON`) so both formats share one
parsing/validation path, including `signerConfig` (`json.RawMessage`)
and `agglayerClient`. Field names are identical in both formats.
- Added `parameters.toml.example` (each field commented with its
description + default) and converted the `config-examples/` to TOML
(`zkevm-cardona.toml`, `zkevm-mainnet.toml`); removed the `.json`
variants. `.gitignore` now also ignores `parameters.toml`.

**`exitAddress` validation**
- `LoadConfig` now rejects a missing or zero-address `exitAddress`.
Docs/examples updated (the field was previously documented as optional,
defaulting to the zero address) and `exitAddress` ships commented-out in
the example configs so the operator must set their own.

**New options**
- `options.verifyNewLocalExitRootUsingShadowFork` — `true` (default).
`true` verifies the LER on the Anvil shadow-fork (requires Anvil);
`false` computes it off-chain from the lite tree (no Anvil, trusts
off-chain leaf encoding/metadata).
- `options.ignoreUnsupportedL2Events` — `false` (default). Downgrades
the lite syncer's abort on unsupported events to a warning.

**Renamed options** (to the `ignore*` convention)
- `abortOnGenesisBalance` → `ignoreGenesisBalance` *(polarity inverted:
default `false` = abort)*
- `continueOnTraceError` → `ignoreOnTraceError`
- `continueIfBalanceMismatch` → `ignoreBalanceMismatch`

**Removed**
- `options.depositOrderSource`.

## ✅ Testing
- 🤖 **Automatic**: `go test ./tools/exit_certificate/...` passes (incl.
`bridgesyncerlite`, `step_g_order_test.go`, and `config_test.go` with
the new `TestLoadConfig_MissingExitAddress` /
`TestLoadConfig_ZeroExitAddress`). `go build`, `go vet`, `gofmt`, and
`golangci-lint` clean.
- 🖱️ **Manual**: run `--step g` (G1+G2) and Step I; confirm
`step-g-new-local-exit-root.json` + `step-g-reordered-certificate.json`
are produced and the lite tree root matches the contract `getRoot()` in
verify mode.
- 🌐 **Mainnet**: the off-chain computation was tested on mainnet against
the shadow-fork and both produced the same `LocalExitRoot` (shadow-fork
verification took 13.5h, with a total of 975,646 bridges generated).

## 🐞 Issues
- Closes agglayer/pm#352 (agglayer/pm#352)
- Closes agglayer/pm#348 (agglayer/pm#348)

## 📝 Notes
- `--step g` runs G1+G2; `g1`/`g2` run individually; `g` expands to
`g1,g2` in ranges.
- Anvil (Foundry) is required only in the default shadow-fork
verification mode.
- Targets `feature/exit-certificate-tool` (the exit-certificate
integration branch), not `develop`.
- **Step G1 ETA refinement**: the fetch-progress ETA now measures
throughput over a trailing time window instead of the lifetime average,
so it is not skewed by the fast empty low-block windows at the start.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… data (#1650)

## 🔄 Changes Summary

- **New `tools/exit_certificate_claimer` service**: a read-only HTTP
service that, given a destination address, lists the bridge exits
available for that address and assembles the full set of parameters
needed to call `AgglayerBridge.claimAsset` on L1. It builds the
`claimAsset` arguments from three sources: the signed certificate
(`exit-certificate-signed.json`), the L2 local exit tree
(`step-g-l2bridgesyncerlite.sqlite`) and the L1 Info Tree DB. API base
path `/claimer/v1`, endpoints: `GET /health`, `GET /bridges`, `GET
/claim-params`. See `SPEC.md` / `README.md`.
- Proofs are anchored to the L1 settlement leaf; the certificate's
`new_local_exit_root` must already be settled on L1 (`/claim-params`
returns `409` if not).
- Config can be provided directly (JSON/TOML, see
`service/config.toml.example`) or **derived from an `exit_certificate`
config** via `--exit-certificate-config`.
- On startup the claimer derives the settlement GER from the WAIT step
result and either serves from the already-synced L1 Info Tree DB or
syncs L1 only until that GER is indexed.
- **Helper scripts** (`tools/exit_certificate_claimer/scripts/`):
`list-bridges.sh`, `claim-asset.sh`, and `claim-all.sh` (claims every
pending deposit for all addresses of an exit run).
- **`exit_certificate` tool changes** required for the claimer flow:
- **WAIT step**: confirm L1 settlement and persist
`step-wait-result.json`; handle `verifyBatches` events.
  - **Step F**: admin toggle.
- **Step G2**: generate metadata when the shadow fork is off; use raw
metadata.
- **Step H**: refuse to proceed when the agglayer still has a
non-settled (open) certificate for the network; clearer LocalExitRoot
mismatch error.
- **Build**: `make build-tools` now also builds
`exit_certificate_claimer`, and individual `build-<tool>` targets were
added (`build-exit_certificate`, `build-exit_certificate_claimer`,
etc.).

## ⚠️ Breaking Changes

- 🛠️ **Config**: None to existing components — the new config is scoped
to the new tool.
- 🔌 **API/CLI**: Adds a new standalone `exit_certificate_claimer`
binary; no changes to existing interfaces. The claimer's default HTTP
port is `7080`.
- 🗑️ **Deprecated Features**: None.

## 📋 Config Updates

- 🧾 New tool config only:
`tools/exit_certificate_claimer/service/config.toml.example` (or derive
it from an existing `exit_certificate` config via
`--exit-certificate-config`).

## ✅ Testing

- 🤖 **Automatic**: `service/certificate_test.go`,
`service/claimer_test.go`, and
`exit_certificate/step_wait_verifybatches_test.go` (plus updated step_f
/ step_g2 / config tests). Verified locally: `make build`, `go test
./...` (pass), and `golangci-lint run` (0 issues).
- 🖱️ **Manual**: Build with `make build-exit_certificate_claimer`, run
the service against an exit-certificate output dir
(`--exit-certificate-config`), then exercise the scripts
(`list-bridges.sh`, `claim-asset.sh`, `claim-all.sh`).

## 🐞 Issues

- Related: agglayer/pm#364

## 🔗 Related PRs

- Builds on #1633 (Step G1/G2 split + `bridgesyncerlite`), already
merged into `feature/exit-certificate-tool`.

## 📝 Notes

- Backend design and API documented in
`tools/exit_certificate_claimer/{SPEC,README}.md`; scripts documented in
`tools/exit_certificate_claimer/scripts/README.md`.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…aim flow

- Build via `make build-exit_certificate`; run with `./target/exit_certificate`
- Add Requirements section (PP, threshold=1, prior settled certificate, stopped sequencer)
- Add "no unclaimed L1→L2 bridges" limitation
- Document zkEVM config-examples and the missing options
  (ignoreUnsupportedL2Events, verifyNewLocalExitRootUsingShadowFork)
- Fix step descriptions to match code: Step A (A1/A2), SUBMIT (pending-cert
  rejection + L1 block capture), WAIT (L1 settlement confirmation)
- Document `--step` ranges (a-c, g-, 0-wait)
- Note SUBMIT/WAIT must be run explicitly after the pipeline
- Replace Output section with a Result section covering the claim files
  consumed by exit_certificate_claimer
- Drop the obsolete external getLBT reference

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…and CI workflow (#1655)

## 🔄 Changes Summary
- Add the **exit-certificate E2E test harness** under
`test/e2e-exit_certificate/`: spins up a Kurtosis CDK network
(`run_network.sh`), prepares a settled certificate
(`prepare_network.sh`), and runs the `exit_certificate` tool
(`run_exit_tool.sh`), orchestrated by `run_e2e_test.sh` (+ `helper.sh`,
`single_op_pessimistic_args.json`, `README.md`).
- Add supporting scripts under `tools/exit_certificate/scripts/`:
  - `bridge_l2_to_l1.sh` — L2→L1 bridge to produce a certificate.
- `agglayer_certificate_status.sh` — poll/wait for certificate
settlement.
  - `export_kurtosis_env.sh` — export Kurtosis env vars for the scripts.
  - `agglayer_status/main.go` — agglayer status helper.
  - Update `bridge_l1_to_l2.sh`.
- Add CI workflow
`.github/workflows/test-e2e-exit_cenrtificate_tool.yml` that runs the
E2E test on PRs touching `tools/exit_certificate/**` (also
`workflow_dispatch`).

## ⚠️ Breaking Changes
- None.

## 📋 Config Updates
- None.

## ✅ Testing
- 🤖 **Automatic**: New GitHub Actions workflow runs
`test/e2e-exit_certificate/run_e2e_test.sh` on PRs that modify
`tools/exit_certificate/**` (Kurtosis CDK network → settled certificate
→ exit_certificate tool).
- 🖱️ **Manual**: `./test/e2e-exit_certificate/run_e2e_test.sh` locally
(requires Docker, Kurtosis CLI, Foundry `cast`, Go).

## 🐞 Issues
- N/A

## 🔗 Related PRs
- #1654 (feat: subtract genesis preload from EOA balances in Step B)

## 📝 Notes
- The E2E harness clones a pinned Kurtosis CDK commit and manages its
own `aggkit` enclave; the workflow stops the enclave on completion.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… pending-bridges `dest_net` (#1683)

## 🔄 Changes Summary
- `step_e.fetchZkevmPendingBridges()` hardcoded `dest_net=1` in the `GET
/pending-bridges` query string. For any L2 chain whose network ID is not
`1`, Step E's bridge service cross-check queried deposits targeting a
different chain, causing the check to silently pass or spuriously fail
against the wrong deposit set.
- The function now takes a `destNet` parameter and the caller passes
`cfg.L2NetworkID`.

## ⚠️ Breaking Changes
- None.

## 📋 Config Updates
- None.

## ✅ Testing
- 🤖 **Automatic**: `TestFetchZkevmPendingBridges` now asserts the
request's `dest_net` query param matches the configured network ID
(locking the fix). All `tools/exit_certificate` unit tests pass.

## 🐞 Issues
- Closes #1681

## 📝 Notes
- The aggkit bridge service path already filtered by `cfg.L2NetworkID`
(`destination_network`); only the zkevm path was affected.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…in Step B (#1675)

## 🔄 Changes Summary
- **Fix (High):** `fetchAllTokenBalances` (Step B1) silently discarded
per-token RPC errors (`log.Warnf` + `return`), leaving the failed token
absent from the balance map. Step C then treated its entire LBT supply
as SC-locked and Step D routed the whole supply to `exitAddress`,
excluding the real EOA holders from the certificate.
- Made it **fail-fast**, consistent with its sibling helpers
(`classifyAddresses`, `fetchETHBalances`): `fetchAllTokenBalances` now
returns an `error` and uses `errgroup.WithContext` (limited to
`tokenConcurrency`) to cancel the remaining scans on the first failure.
`RunStepB1` propagates it and aborts the pipeline.
- Added a doc comment explaining why the error must not be dropped.

## ⚠️ Breaking Changes
- None. No config, API, or CLI changes. Behaviour change only: a
persistent per-token RPC failure now aborts the pipeline (after the
existing RPC retries) instead of producing a misrouted certificate.

## 📋 Config Updates
- None.

## ✅ Testing
- 🤖 **Automatic:** Added `TestFetchAllTokenBalancesFailFast` (a failing
`balanceOf` batch must return an error, never omit the token). Updated
`TestFetchAllTokenBalances` to the new signature. Full package suite
passes (`go test ./tools/exit_certificate/`) and `golangci-lint` reports
0 issues.

## 🐞 Issues
- Closes #1679

## 🔗 Related PRs
- N/A

## 📝 Notes
- Chose fail-fast as the default without adding an opt-out flag (e.g.
`ignoreTokenBalanceFetchError`) to keep the config surface minimal; can
add one if a knowingly-degraded test mode is desired.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…deABIString (#1684)

## 🔄 Changes Summary
- Fix a uint64 integer overflow in `step_e.decodeABIString()` (SP002). A
maliciously crafted L1 ERC-20 whose `name()`/`symbol()` returns an
oversized ABI length field could make `64+strLen` wrap past `MaxUint64`
to a small value, bypassing the bounds guard and triggering a slice
out-of-range **panic** that aborts the exit certificate tool for any
chain with an unclaimed L1→L2 deposit of such a token.
- Reject length fields that don't fit in `uint64` (`big.Int.IsUint64()`)
and compare `strLen` against the remaining byte count without the
overflowing addition (`strLen > uint64(len(data))-twoABIWords`, safe
since `len(data) >= 64`).

## ⚠️ Breaking Changes
- None.

## 📋 Config Updates
- None.

## ✅ Testing
- 🤖 **Automatic**: Added `TestDecodeABIString` with regression cases —
`MaxUint64` length field (the overflow case) and a length field larger
than `uint64` — asserting no panic and `""` return, plus
valid/empty/too-short/length-exceeds-data cases. Full `go test
./tools/exit_certificate/...` passes; `go vet` and `gofmt` clean.
- 🖱️ **Manual**: n/a (deterministic decode logic covered by unit test).

## 🐞 Issues
- Closes #1680

## 📝 Notes
- The guard now also rejects length fields exceeding `uint64` range,
which `big.Int.Uint64()` would have silently truncated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…uild version (#1688)

## 🔄 Changes Summary

**Goal:** make every `exit_certificate` run **traceable** — at a glance
you can tell which build, which config and which exact command produced
a given output/log.

To achieve that:
- **run.go**: logs an *execution traceability* banner line-by-line (same
format as the rest of the logs) at the start of `Run`, reporting:
  - build version, git revision, git branch and build date,
  - Go version and OS/Arch,
- the **sha256 of the config file** (so the exact parameters used can be
verified),
  - the **command-line arguments** the tool was invoked with.
- **Makefile**: the `exit_certificate` build target now passes `-ldflags
"all=$(LDFLAGS)"`, so `Version`, `GitRev`, `GitBranch` and `BuildDate`
are injected into the binary (previously left at their defaults —
without this the banner/`--version` would report placeholders).
- **cmd/main.go**: `--version` prints the full build info (version, git
revision, branch, build date, Go version, OS/Arch) via a custom
`cli.VersionPrinter` calling `aggkit.PrintVersion`.

### Output example:
```
2026-06-30T15:23:42.172+0200    INFO    exit_certificate/run.go:31      ╔═══════════════════════════════════════════╗   {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
2026-06-30T15:23:42.173+0200    INFO    exit_certificate/run.go:32      ║   Exit Certificate Tool — Traceability    ║   {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
2026-06-30T15:23:42.173+0200    INFO    exit_certificate/run.go:33      ╚═══════════════════════════════════════════╝   {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
2026-06-30T15:23:42.173+0200    INFO    exit_certificate/run.go:34      Version:      v0.10.0-rc1-72-gfc332f25  {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
2026-06-30T15:23:42.173+0200    INFO    exit_certificate/run.go:35      Git revision: fc332f2  {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
2026-06-30T15:23:42.173+0200    INFO    exit_certificate/run.go:36      Git branch:   feat/exit-certificate-tool-version        {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
2026-06-30T15:23:42.173+0200    INFO    exit_certificate/run.go:37      Built:        Tue, 30 Jun 2026 15:16:17 +0200   {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
2026-06-30T15:23:42.173+0200    INFO    exit_certificate/run.go:38      Go version:   go1.25.7  {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
2026-06-30T15:23:42.173+0200    INFO    exit_certificate/run.go:39      OS/Arch:      linux/amd64       {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
2026-06-30T15:23:42.173+0200    INFO    exit_certificate/run.go:40      Config file:  tmp/exit_certificate-kurtosis.json (sha256: e2f0a892dcb070f888037f0f88bf47232e7667f61e6449419fca9c92ba77ce01)     {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
2026-06-30T15:23:42.173+0200    INFO    exit_certificate/run.go:41      Command line: ./target/exit_certificate -c tmp/exit_certificate-kurtosis.json   {"pid": 560596, "version": "v0.10.0-rc1-72-gfc332f25"}
```

## ⚠️ Breaking Changes
- None.

## 📋 Config Updates
- None.

## ✅ Testing
- 🤖 **Automatic**: `make build-exit_certificate` builds with injected
ldflags.
- 🖱️ **Manual**: `./target/exit_certificate --version` prints full build
info; running any step logs the traceability banner (verified config
sha256 + command line render correctly).

## 📝 Notes
- `log/log.go` is shared infrastructure and is intentionally left
untouched; the per-trace `version` field stays as-is for all binaries.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@joanestebanr joanestebanr force-pushed the feature/exit-certificate-tool branch from b546dad to 2433abe Compare June 30, 2026 14:12
joanestebanr and others added 4 commits July 6, 2026 16:08
…is premints (genesisPrefundETHWei, capMode, nativeSCLockedFromContracts) (#1694)

## 🎯 Motivation / Context
This PR adapts the exit-certificate tool to the particularities of
**testnets**, where native funds
are pre-loaded at genesis (e.g. 110,000 ETH on Kurtosis enclaves,
100,000 ETH on Cardona). That
premint breaks several steps of the pipeline, because it inflates
account balances without any
matching agglayer deposit:

- The **Step F** balance check fails: the certificate's native exits
include the premint, but the
agglayer balance and the Step 0 LBT only count genuinely bridged funds.
- The **Step C** native SC-locked formula (`LBT − EOA_accumulated`)
underflows, clamps to 0 and
  silently drops contract-held ETH from the certificate.
- The premint itself has **no collateral on the agglayer**, so it can
never be bridged out — the
  certificate must always be capped to the bridged amount.

## 🔄 Changes Summary
- **`options.genesisPrefundETHWei`** — declares the native amount (Wei,
decimal string) preminted at
  genesis:
- **Step B** verifies the declared value against the detected genesis
ETH preload total (sum of
balances at block 0); a mismatch is fatal even with
`ignoreGenesisBalance=true`, since Step F
would otherwise discount the wrong amount. The genesis-preload error
message now explains that
pre-funding accounts at genesis is not allowed and points at
`ignoreGenesisBalance=true`.
- **Step F** subtracts the value from the native-token **certificate
sum** before both the
agglayer three-way and offline comparisons (via
`discountGenesisPrefund`, floored at zero),
logging the certificate total, the pre-fund and the difference — so the
check balances against
    the genuinely bridged amount.
- The pre-fund has **no agglayer collateral**, so even when every check
matches Step F emits
`step-f-capped-certificate.json` trimming the native exits to
`min(agglayer, lbt)` — no
    `ignoreBalanceMismatch` needed.
  - Validated by `LoadConfig` (non-negative base-10 integer).
- **`options.capMode`** — `"amount"` (default) or `"appearance"`.
Controls how Step F allocates each
token's cap budget when trimming exits. `"amount"` serves the
smallest-amount exits first so the
largest holders (the premint whales) are capped/dropped first;
`"appearance"` serves exits in
appearance order. Surviving exits keep their original order in both
modes. `capCertificateExits`
  reworked into a greedy per-token allocator.
- **`options.nativeSCLockedFromContracts`** — when `true` (the default),
Step C measures the native
token's SC-locked value directly by summing the ETH held by contract
accounts at `targetBlock`
(excluding the L2 bridge reserve), instead of deriving it as `LBT −
EOA_accumulated`, which
underflows on premint chains. Wrapped tokens keep the formula; set to
`false` to fall back to the
  derivation for the native token.
- **Step A no longer excludes the zero address** — `0x000…000` can hold
value (e.g. burned funds)
that the certificate must account for, so it stays in the collected
address set.
- **Step F agglayer LBT dump** — whenever `agglayerAdminURL` is set,
Step F queries
`admin_getTokenBalance` once up front and writes the raw LBT to
`step-f-agglayer-lbt.json`,
regardless of comparison mode (skipped when `OutputDir` is unset, i.e.
hand-built configs). In
agglayer mode the response is reused (no second RPC). Added
`scripts/get-agglayer-lbt.sh` to fetch
  the same LBT manually.
- **Step F mismatch logs** now include the signed `certificate−agglayer`
/ `certificate−lbt`
  differences (collapsed into one when LBT == agglayer).

## ⚠️ Breaking Changes
- `capMode` defaults to `"amount"`: with `ignoreBalanceMismatch=true`,
the default cap outcome
  changes (previously appearance-order allocation).
- `nativeSCLockedFromContracts` defaults to `true`: Step C now measures
the native SC-locked value
from contract balances by default (set to `false` for the previous `LBT
− EOA` derivation).
- Step A now includes the zero address in the collected set, so its
balances can appear in the
  certificate.

## 📋 Config Updates
Three new fields under `options` (all reflected in
`parameters.toml.example` /
`parameters.json.example`):

- 🧾 **`genesisPrefundETHWei`** — amount of native token preminted at
genesis, in **Wei**, as a
  **decimal string**.
- *Purpose:* tells the tool how much native value exists on the chain
without agglayer collateral.
Step B verifies it against the preload actually detected at block 0 (a
mismatch aborts, even
with `ignoreGenesisBalance=true`); Step F discounts it from the native
certificate sum before
the balance comparison and always emits a capped certificate trimming
the native exits to
    `min(agglayer, lbt)`, since the premint can never be bridged out.
- *Values:* `""` (default — feature disabled, no discount and no premint
capping) or any
non-negative base-10 integer, e.g. `"100000000000000000000000"` (100,000
ETH). Negative or
    non-numeric values are rejected by `LoadConfig`.
- 🧾 **`capMode`** — allocation order used when Step F trims a token's
bridge exits to its cap
  budget.
- *Purpose:* decides **which holders absorb the cut** when the
certificate's exits exceed the
collateral (mismatch with `ignoreBalanceMismatch=true`, or the premint
capping above).
- *Values:* `"amount"` (default) — serves the smallest exits first, so
the largest holders
(typically the premint whales) are capped/dropped first and small
legitimate holders keep their
full exits; `"appearance"` — serves exits in the order they appear in
the certificate. Any other
value is rejected by `LoadConfig`. Surviving exits keep their original
order in both modes.
- 🧾 **`nativeSCLockedFromContracts`** — how Step C computes the
**native** token's SC-locked value
  (the amount bridged to `exitAddress`).
- *Purpose:* the derived formula `LBT − EOA_accumulated` underflows on
premint chains (EOA
balances include the premint, the LBT does not), clamping to 0 and
silently dropping
    contract-held ETH from the certificate.
- *Values:* `true` (default) — measure it directly by summing
`eth_getBalance` of every contract
account at `targetBlock`, excluding the L2 bridge (its balance is the
un-released native
reserve); `false` — keep the `LBT − EOA` derivation. Wrapped tokens
always use the formula,
    regardless of this flag.

Example configs updated accordingly:

- 🧾 `config-examples/zkevm-cardona.toml` sets `genesisPrefundETHWei` to
the 100,000 ETH minted at
  genesis on Cardona.
- 🧾 `scripts/configuration_based_on_kurtosis.sh` sets
`genesisPrefundETHWei` to the 110,000 ETH
preminted by the Kurtosis enclave (overridable via the
`GENESIS_PREFUND_ETH_WEI` env var).

## ✅ Testing
- 🤖 **Automatic**: `go test ./tools/exit_certificate/...` — added tests
for the pre-fund discount
  and always-cap behaviour (`TestDiscountGenesisPrefund`,
`TestCompareTokenBalances_GenesisPrefundDiscount`,
`TestRunStepF_PrefundMatchedStillCapsToLBT`,
`TestRunStepF_NoPrefundNoCapOnAllMatch`), the Step B preload
verification
(`TestCheckDeclaredGenesisPrefund`,
`TestRunStepB1GenesisPrefundDeclared`), the Step C contract
measurement (`TestRunStepC_NativeSCLockedFromContracts`,
`TestSumContractNativeBalances`,
`TestApplyNativeContractLocked`), cap allocation
(`TestLoadConfig_CapMode`,
`TestCapCertificateExits_ByAmount*`), config loading
(`TestLoadConfig_GenesisPrefundETHWei`) and
  the LBT dump guard (`TestRunStepF_EmptyOutputDir_SkipsLBTDump`).
- 🧪 **Manual**: full pipeline run against a Kurtosis enclave
  (`scripts/configuration_based_on_kurtosis.sh`).

## 📝 Notes
- An `ignoreAddresses` option (exclude specific accounts from the
certificate exits) was initially
part of this PR but was removed before merging: it is not in use and
complicated other in-flight
PRs. The branch name still reflects it; the branch will be deleted after
merge.
- README/CLAUDE.md docs were audited against the code and corrected
(e.g. `stepAWindowSize` default,
`rollupManagerAddress`, Step D holder-bridge exits, conditional Step C/E
outputs).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… in Step G (#1698)

## 🔄 Changes Summary
- Step G misclassified bridge exits for **external native tokens**
(another network's native asset, represented on this L2 as a wrapped
ERC-20 with `OriginTokenAddress=0x0` but a non-local `OriginNetwork`) as
the **local gas token**, computing `NewLocalExitRoot` from the wrong
leaves. The internal consistency check still passed because both the
shadow-fork replay and the off-chain tree builder made the same
misclassification.
- `isNativeBridgeExit` (`step_g2.go`): removed the standalone
`OriginTokenAddress == 0x0` condition. An exit is now native only when
`TokenInfo == nil` or its origin identity (network **and** address)
matches the gas token's.
- `buildLiteTreeFromCertificate` (`step_g_events.go`): the off-chain
tree builder now selects the leaf origin via `isNativeBridgeExit`
instead of an address-only check, keeping it in lock step with the
shadow-fork replay's native/ERC-20 decision.
- `resolveTokenAddresses` already delegated to `isNativeBridgeExit`, so
external native tokens now correctly resolve their L2 wrapped address
instead of being skipped.

## ⚠️ Breaking Changes
- None.

## 📋 Config Updates
- None.

## ✅ Testing
- 🤖 **Automatic**: Added AET-01 regression case to
`TestIsNativeBridgeExit` (`(OriginNetwork=5, OriginTokenAddress=0x0)`
must not be native). Full package suite passes: `go test
./tools/exit_certificate/`. `golangci-lint run
./tools/exit_certificate/...` clean.

## 🐞 Issues
- Closes #1693

## 📝 Notes
- The tool only supports ETH-gas-token chains (Step CHECK #7), so in
practice `gasToken` is `(network=0, addr=0x0)`; the fix is general
regardless of gas token identity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…st-snapshot deposits cannot block the exit pipeline (#1704)

## 🔄 Changes Summary
- New **optional** config field `options.l1EndBlock` (`uint64`, next to
`options.l1StartBlock`; a plain L1 block number, `0` = unset).
- **Step E**: the L1 `BridgeEvent` scan for unclaimed L1→L2 deposits now
ends at the cutoff instead of the current latest L1 block
(`resolveL1EndBlock`). The bridge service cross-check (both `aggkit` and
`zkevm` flavours) filters out pending entries whose `block_num` is past
the cutoff, logging how many were ignored.
- **Step I**: the backward `UpdateL1InfoTreeV2` scan for
`L1InfoTreeLeafCount` starts at the cutoff when set (via the same shared
`resolveL1EndBlock`), making the leaf count deterministic and immune to
post-snapshot L1 activity.
- **Validation**: `LoadConfig` rejects an `l1EndBlock` below
`options.l1StartBlock`; at run time `resolveL1EndBlock` rejects a cutoff
beyond the current L1 head with an actionable error — it is almost
surely a misconfiguration (e.g. an L2 block number), and some L1 clients
otherwise fail the scan with a cryptic `invalid block range params`.
- Docs updated (`README.md`, tool `CLAUDE.md`,
`parameters.json.example`, `parameters.toml.example`).

## Why this fixes AET-03 (#1703)
The attack in AET-03 works because the pipeline freezes the **L2** state
at `targetBlock` (resolved once in Step 0), but Step E scanned **L1** up
to whatever `eth_blockNumber` returned at run time. After the sequencer
is stopped, an unprivileged user can submit a dust L1→L2 asset deposit;
Step E picks it up, `isClaimed` on the frozen L2 is necessarily false,
and with the default `ignoreUnclaimed=false` the pipeline aborts. Since
the sequencer is down, the deposit can never be claimed — the block is
**permanent**, and re-running never helps.

`l1EndBlock` gives L1 the same frozen-cutoff semantics the L2 already
has: the operator pins the scan to a block at/after the moment the
sequencer was stopped, so any deposit submitted after that point is
simply outside the scanned range and cannot abort the run. Two
complementary holes are closed at the same time:

1. **Bridge service cross-check** — without the filter, the post-cutoff
deposit would still appear in the service's pending set, trip the
"service reports deposits not found by L1 scan" mismatch, and block the
pipeline through that path instead.
2. **Step I `L1InfoTreeLeafCount`** — a post-snapshot deposit with
`forceUpdateGlobalExitRoot` emits a new `UpdateL1InfoTreeV2`, so
scanning from `latest` would reference attacker-influenced state;
anchoring the scan at the cutoff keeps the referenced leaf pre-snapshot
and already settled.

When the field is unset (the default, `0`), behaviour is exactly as
before (latest L1 block), so existing configs are unaffected.

## ⚠️ Breaking Changes
- None. The new field is optional and defaults to the previous
behaviour.

## 📋 Config Updates
- 🧾 **Diff/Config snippet** (JSON and TOML, same key):
  ```toml
  [options]
# OPTIONAL — L1 cutoff block for the L1 reads (Step E unclaimed scan +
bridge service
# cross-check, Step I UpdateL1InfoTreeV2 scan). Pick a block at or after
the sequencer
# stop. Must be >= l1StartBlock and must not exceed the current L1 head.
  # Default: 0 (no cutoff: use the latest L1 block).
  l1EndBlock = 0
  ```

## ✅ Testing
- 🤖 **Automatic**: `go test ./tools/exit_certificate/...` (all green)
and `golangci-lint run ./tools/exit_certificate/...` (0 issues). New
unit tests:
- `TestResolveL1EndBlock` — no cutoff → latest; cutoff at/below the head
→ returned as-is; cutoff beyond the head → clear config error.
- `TestFetchAggkitPendingBridgesCutoff`,
`TestFetchZkevmPendingBridgesCutoff`,
`TestFetchZkevmPendingBridgesCutoffBadBlockNum` — bridge service entries
past the cutoff are dropped; unparseable `block_num` errors instead of
being silently dropped.
- `TestFetchL1InfoTreeLeafCount` (new `step_i_test.go`) — Step I scans
from the cutoff when set, from latest otherwise.
- `TestLoadConfig_L1EndBlock` — parse and the `l1StartBlock` validation.
- 🖱️ **Manual** (Kurtosis, dust asset deposit at L1 block 41944): with
`l1EndBlock = 41943` (cutoff before the deposit) Step E reports
`Unclaimed L1→L2 deposits: 0` and completes; with a cutoff after the
deposit it is detected as unclaimed and the step aborts as designed;
with `l1EndBlock` past the L1 head the run fails fast with
`options.l1EndBlock N is beyond the current L1 latest block M; ...`
instead of the client's cryptic `invalid block range params`.

## 🐞 Issues
- Closes #1703

## 📝 Notes
- Step CHECK and SUBMIT/WAIT intentionally keep using the latest L1
block: those uses are operational (connectivity check, capturing the
block right before submission), not part of the frozen snapshot.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…T-02) and zero adress issue (AET-08) (#1701)

## 🔄 Changes Summary
- **Step A replaced**: addresses are discovered via a
`debug_accountRange` state dump + `Transfer` event logs per wrapped
token, instead of `debug_traceTransaction` over the whole chain history
— `O(#accounts)` vs `O(#txs)`, and it finds passive token-only holders
that tracing structurally misses. Both sources always run and merge (the
dump covers native-ETH holders and contracts; the logs cover token
holders): they have complementary blind spots, so there is no strategy
option and no fallback — an unusable `debug_accountRange` fails Step A
instead of degrading silently. The trace-based A1/A2 implementation is
removed.
- **Zero address kept in Step A** — `0x00…00` can hold value like any
other account (a plain `transfer(0x0, amount)` is not a burn and native
ETH can be sent there); dropping it left that value uncovered and the
certificate unbalanced against the LBT.
- **Extra ERC-20 holders discovered in Step A** — the `Transfer`-log
scan also covers `options.extraErc20Contracts` (deduplicated against the
wrapped tokens, and it runs even with no wrapped tokens). Step B3 only
probes `balanceOf` against the Step A address set, so a passive holder
of an extra token (no ETH/nonce/code, never touched a wrapped token) was
invisible to both sources and their collateral share flowed to
`exitAddress`; discovery stays in Step A (B3 never discovers addresses),
so `step-a-addresses.json` remains the complete address universe in
single-step mode.
- **`options.capMode` gains `"none"`, the new default** — capping the
certificate is a lossy operation and must now be opted into explicitly.
With `"none"`, Step F fails (`errCapForbidden`) as soon as any bridge
exit would have to be trimmed or dropped, on every capping path: the
mismatch cap (`ignoreBalanceMismatch=true`), the genesis pre-fund trim
(which caps even on allMatch, so `genesisPrefundETHWei` now requires a
trimming mode) and the final-certificate re-cap in the pipeline. A no-op
cap (everything fits the budget) still passes. `"amount"`/`"appearance"`
keep their previous trimming behavior.
- The Kurtosis config generator (`configuration_based_on_kurtosis.sh`)
now emits `capMode: "amount"` (its config declares
`genesisPrefundETHWei`, whose premint trim needs a trimming mode) and
`ignoreBalanceMismatch: false` (real mismatches must abort).
- **E2E harness extended with the AET-02 scenario and split into
numbered stages** (`test/e2e-exit_certificate/`): `10-run_network` →
`20-prepare_network` → `30-stop_sequencer` →
`40-generate_exit_certificate` → `45-check_exit_certificate` →
`50-submit_exit_certificate` → `60-claim_exit_certificate_funds` (the
old `run_exit_tool.sh` is gone). The sequencer stop now halts only the
block producers (`op-batcher`/`op-cl`), keeping the `op-el` RPC alive so
the tool can still read the frozen L2 state.

## ⚠️ Breaking Changes
- 🛠️ **Config**: `options.capMode` now defaults to `"none"`: runs that
relied on the previous `"amount"` default to trim the certificate (any
`genesisPrefundETHWei` run, or `ignoreBalanceMismatch=true` with real
mismatches) now fail in Step F unless `capMode` is set explicitly to
`"amount"` or `"appearance"`.
- 🛠️ **Config**: `options.ignoreOnTraceError` and
`options.stepAWindowSize` removed (the trace-based Step A and the
receipt-harvesting fallback no longer exist). `options.addressDiscovery`
(present in intermediate revisions of this PR, never released) is also
gone — Step A always runs both sources.
- 🔌 **API/CLI**: `--step a1` / `--step a2` removed (`a` has no sub-steps
anymore); `step-a1-*`, `step-a2-*` and `step-a-failed-traces.json` are
no longer produced.

## 📋 Config Updates
- 🧾 `capMode` accepts `"none"` (new default) besides `"amount"` and
`"appearance"`; `zkevm-cardona.toml` sets `capMode = "amount"`
explicitly because its `genesisPrefundETHWei` always requires trimming.

## ✅ Testing
- 🤖 **Automatic**: `go test ./tools/exit_certificate/...` and
`golangci-lint` clean. New unit tests for Step A discovery (pagination,
dialect detection, Transfer extraction, source merging, extra-ERC-20
scanning — including token dedup and the no-wrapped-tokens case —,
truncation/empty-dump/dump-unavailable guards), zero-address handling,
and capMode `"none"` (no-op pass-through, fail-on-trim, fail-on-drop,
plus RunStepF-level prefund and mismatch failures).
- 🧪 **E2E (covers the AET-02 fix end-to-end)**:
`test/e2e-exit_certificate/run_e2e_test.sh` reproduces the exact bug
scenario on a Kurtosis enclave — `20-prepare_network.sh` deploys a
`MintableERC20` on L1, bridges it to L2 and transfers part of the
wrapped balance to a **passive account** that never sends any L2
transaction (asserted: nonce 0 and zero native balance, so it is
invisible to the state dump and only discoverable through `Transfer`
logs). After generating the certificate, `45-check_exit_certificate.sh`
asserts it contains exactly two bridge exits for that ERC-20 — the
active holder and the passive recipient, each with the exact expected
amount — before the certificate is submitted, settled on L1 and the
funds claimed. With the old trace-based Step A this check fails (the
passive recipient is missing); with the new Step A it passes.
- 🖱️ **Manual**: the state-dump Step A approach was validated in #1687
against Bali (block 24,000,064): ~2m45s vs ~7h46m for the trace-based
scan, 321,581 addresses (99.95% coverage of the trace set plus 5,987
extra real holders).

## 🐞 Issues
- Closes #1699 — AET-02: passive ERC-20 recipients (accounts that only
ever *received* tokens) are structurally invisible to
`debug_traceTransaction`, so they were omitted from the certificate and
their balances wrongly absorbed into the SC-locked residual. The
`Transfer`-log discovery in the new Step A covers them.
- #1700 — the zero-address fix included here (second commit) is what
that issue asked for; the issue was already closed manually.
- Closes #1707 — AET-08: `dialBridge()` binds the bridge contract
without rejecting a zero `BridgeAddr`, so the lite syncer would silently
scan the wrong address and `BuildTree()` would return the zero hash as
the LER without any error.

Not addressed here (still open, tracked elsewhere): #1680 is already
fixed by #1684 (merged into the feature branch; it will auto-close when
the feature branch reaches `develop`), and #1693 (AET-01) is being
handled in `fix/exit-certificate-tool_aet01-native_token`.

## 🔗 Related PRs
- Imports and supersedes #1687 (its Step A lands here as the default,
not as the opt-in `aalt` step)
- Rebased on top of #1694, which already landed `genesisPrefundETHWei`,
`capMode`, `nativeSCLockedFromContracts` and the Step F agglayer LBT
dump — those changes are no longer part of this PR. The
`ignoreAddresses` option was dropped intentionally (no longer wanted).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

…deployment (#1690)

## 🔄 Changes Summary
Packaging and deployment preparation for the exit_certificate_claimer
service.
- 🐳 **Dockerfile**: ship the `exit_certificate` (generator) and
`exit_certificate_claimer` (HTTP service) binaries in the aggkit image
under
`/usr/local/bin`, so the tools travel alongside `aggkit` and the same
image can
  be used to deploy the exit_certificate_claimer.
- 📦 **docker-compose**: add
`tools/exit_certificate_claimer/docker/docker-compose.yml`
to run the exit_certificate_claimer as a read-only HTTP service. It
builds from
  the repo-root Dockerfile, overrides the entrypoint with the
  exit_certificate_claimer binary, and derives its whole config from the
exit_certificate `parameters.toml` via `--exit-certificate-config`.
Mounts the
exit_certificate working dir at `/data` (read-write, since L1 sync keeps
its
SQLite DBs updated) and wires `host.docker.internal` so a host-pointing
`l1RpcUrl` is reachable on Linux. Defines a `healthcheck:` and documents
the
volume-permission expectations (the container runs as `appuser`, uid
1000).
- 🩺 **healthcheck subcommand**: `exit_certificate_claimer healthcheck`
probes
`GET /claimer/v1/health` of a running claimer and exits 0 (healthy) or
1.
The production image ships without a shell or curl, so the binary itself
performs the HTTP probe; the docker-compose healthcheck is backed by it.
- 📜 **JSON logs**: new `--log-json` flag switches the claimer logs to
JSON
format (aggkit's `production` log environment, zap JSON encoder); the
default
remains the human-readable console format. Fatal startup errors are
emitted
through the configured logger too (JSON or console), instead of
urfave/cli's
plain `Error: ...` line. Exposed in docker-compose via
`CLAIMER_LOG_JSON`
  (default `false`).
- 🧪 **e2e docker mode**: `60-claim_exit_certificate_funds.sh` gains
`CLAIMER_MODE=docker` to run the claimer through the docker-compose
deployment
instead of a host binary (a sibling config copy rewrites
`127.0.0.1`/`localhost`
to `host.docker.internal` so the container reaches the
Kurtosis-published L1
RPC). The CI workflow sets it, so the e2e exercises the same artifact
users
deploy — image build from the repo-root Dockerfile included — and now
also
triggers on `tools/exit_certificate_claimer/**` and `Dockerfile`
changes. The
compose service user is parameterizable (`CLAIMER_USER`, default
`appuser`) so
  L1 sync can write its SQLite DBs whatever the host uid.
- ⚙️ **.env.example**: documented env vars (`EXIT_TOOL_DIR`,
`EXIT_CERT_CONFIG`,
`CLAIMER_ADDRESS`, `CLAIMER_PORT`, `CLAIMER_LOG_JSON`, `AGGKIT_IMAGE`)
consumed
  by compose.
- 🩹 **e2e fix**: `agglayer_status` (`agglayer_certificate_status.sh
--wait`) now
retries on the transient agglayer error raised right after the network
starts,
  before the node has classified the network:

  ```
  ERROR: get network info: failed to get network info: Code: NotFound,
  Message: Network type could not be determined,
  Details: [Reason: GET_NETWORK_INFO_ERROR_KIND_UNKNOWN_NETWORK_TYPE,
Domain: agglayer-node.grpc-api.v1.node-state-service.get_network_info. ]
  ```

`waitForSettled` previously aborted on any `GetNetworkInfo` error,
making the
e2e `prepare_network.sh` step fail intermittently. This specific error
is now
treated as transient and polling continues (respecting the configured
interval
  and timeout); other errors still abort.

## ⚠️ Breaking Changes
- None.

## 📋 Config Updates
- New env-var driven setup for the exit_certificate_claimer (see
`.env.example`).
  No changes to the existing TOML config.
- New optional `--log-json` CLI flag (`CLAIMER_LOG_JSON` in compose) to
emit
  JSON-formatted logs; defaults to the previous console format.

## ✅ Testing
- 🖱️ **Manual**:
- `make build-tools` / `go build ./tools/exit_certificate/...
./tools/exit_certificate_claimer/...` build cleanly.
- From `tools/exit_certificate_claimer/docker/`: `cp .env.example .env`
then
`docker compose up --build` starts the exit_certificate_claimer serving
on `CLAIMER_PORT`.
- `exit_certificate_claimer healthcheck` verified against a live health
endpoint
    (exit 0) and a closed port (exit 1).
- Ran the claimer with and without `--log-json` and verified the log
output
switches between the zap JSON encoder and the console format, including
fatal startup errors (logged as an error line in the active format, exit
code 1).
- 🧪 **Unit**: `go test
./tools/exit_certificate/scripts/agglayer_status/` covers the
  new retry-on-undetermined-network-type path;
`go test ./tools/exit_certificate_claimer/...` covers the healthcheck
probe
(URL building, 200/non-200/unreachable, end-to-end against the real
router).
- 🚀 **E2E**: the exit-certificate tool E2E (CI) runs the claim step with
`CLAIMER_MODE=docker`, validating the full docker deployment end to end:
compose build of the aggkit image, container startup deriving the
config,
in-container L1 sync, `/claimer/v1/health` readiness and claiming every
bridge
  exit against the containerized service.

## 📝 Notes
- The exit_certificate_claimer runs from the same working directory the
exit_certificate tool used (config + `output/`), so no separate config
file is needed.
- PR base: `feature/exit-certificate-tool` (packaging/deployment work
only).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

exit_certificate_tool Tool to create a final exit certificate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

implement exit cert tool

4 participants